|
|
|
@@ -0,0 +1,206 @@
|
|
|
|
|
// 实习工作日志 · 多端同步服务端
|
|
|
|
|
// 零依赖:node:http + node:crypto + JSON 文件存储(个人规模足够,便于备份)
|
|
|
|
|
import http from 'node:http'
|
|
|
|
|
import crypto from 'node:crypto'
|
|
|
|
|
import fs from 'node:fs'
|
|
|
|
|
import path from 'node:path'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
|
|
|
|
|
const PORT = Number(process.env.PORT || 3780)
|
|
|
|
|
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
|
|
|
|
const ITER = 150000
|
|
|
|
|
|
|
|
|
|
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
|
|
|
|
|
|
|
|
// ---------- 存储辅助 ----------
|
|
|
|
|
function readJson(file, fallback) {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf-8'))
|
|
|
|
|
} catch {
|
|
|
|
|
return fallback
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function writeJson(file, data) {
|
|
|
|
|
const tmp = path.join(DATA_DIR, file + '.tmp')
|
|
|
|
|
fs.writeFileSync(tmp, JSON.stringify(data))
|
|
|
|
|
fs.renameSync(tmp, path.join(DATA_DIR, file))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(path.join(DATA_DIR, 'secret.key'))) {
|
|
|
|
|
writeJson('secret.key', { secret: crypto.randomBytes(32).toString('hex') })
|
|
|
|
|
}
|
|
|
|
|
const SECRET = readJson('secret.key', { secret: 'fallback-secret' }).secret
|
|
|
|
|
|
|
|
|
|
const registryFile = 'registry.json'
|
|
|
|
|
let registry = readJson(registryFile, { users: [] })
|
|
|
|
|
function saveRegistry() { writeJson(registryFile, registry) }
|
|
|
|
|
|
|
|
|
|
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
|
|
|
|
|
function loadUserData(userId) {
|
|
|
|
|
return readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
|
|
|
|
|
}
|
|
|
|
|
function saveUserData(userId, data) {
|
|
|
|
|
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
|
|
|
|
|
writeJson('users/' + userId + '.json', data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 认证 ----------
|
|
|
|
|
function pbkdf2Hash(password, salt) {
|
|
|
|
|
return crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256').toString('hex')
|
|
|
|
|
}
|
|
|
|
|
function makeToken(userId) {
|
|
|
|
|
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS }))
|
|
|
|
|
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')
|
|
|
|
|
return payload + '.' + sig
|
|
|
|
|
}
|
|
|
|
|
function verifyToken(token) {
|
|
|
|
|
const parts = String(token || '').split('.')
|
|
|
|
|
if (parts.length !== 2) return null
|
|
|
|
|
const expect = crypto.createHmac('sha256', SECRET).update(parts[0]).digest('base64url')
|
|
|
|
|
const a = Buffer.from(parts[1])
|
|
|
|
|
const b = Buffer.from(expect)
|
|
|
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf-8'))
|
|
|
|
|
if (!payload.userId || payload.exp < Date.now()) return null
|
|
|
|
|
return payload
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function base64url(s) {
|
|
|
|
|
return Buffer.from(s).toString('base64url')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- HTTP 基础 ----------
|
|
|
|
|
function json(res, status, data) {
|
|
|
|
|
const body = JSON.stringify(data)
|
|
|
|
|
res.writeHead(status, {
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
|
'Access-Control-Allow-Origin': '*',
|
|
|
|
|
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
|
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
|
|
|
|
})
|
|
|
|
|
res.end(body)
|
|
|
|
|
}
|
|
|
|
|
function readBody(req, limit = 20 * 1024 * 1024) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
let size = 0
|
|
|
|
|
const chunks = []
|
|
|
|
|
req.on('data', c => {
|
|
|
|
|
size += c.length
|
|
|
|
|
if (size > limit) { reject(new Error('body too large')); req.destroy(); return }
|
|
|
|
|
chunks.push(c)
|
|
|
|
|
})
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}')) }
|
|
|
|
|
catch { reject(new Error('invalid json')) }
|
|
|
|
|
})
|
|
|
|
|
req.on('error', reject)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 路由 ----------
|
|
|
|
|
async function handle(req, res) {
|
|
|
|
|
const url = new URL(req.url, 'http://localhost')
|
|
|
|
|
const route = url.pathname
|
|
|
|
|
|
|
|
|
|
if (req.method === 'OPTIONS') { json(res, 204, {}); return }
|
|
|
|
|
|
|
|
|
|
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true }); return }
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && route === '/api/auth/register') {
|
|
|
|
|
const body = await readBody(req)
|
|
|
|
|
const username = String(body.username || '').trim()
|
|
|
|
|
const password = String(body.password || '')
|
|
|
|
|
if (username.length < 2 || username.length > 20) { json(res, 400, { error: '用户名需 2 到 20 个字符' }); return }
|
|
|
|
|
if (password.length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
|
|
|
|
|
if (registry.users.find(u => u.username === username)) { json(res, 409, { error: '用户名已存在' }); return }
|
|
|
|
|
const salt = crypto.randomBytes(16).toString('hex')
|
|
|
|
|
const userId = 'cu_' + crypto.randomBytes(6).toString('hex')
|
|
|
|
|
registry.users.push({ id: userId, username, pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
|
|
|
|
|
saveRegistry()
|
|
|
|
|
json(res, 200, { userId, username, token: makeToken(userId) })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && route === '/api/auth/login') {
|
|
|
|
|
const body = await readBody(req)
|
|
|
|
|
const username = String(body.username || '').trim()
|
|
|
|
|
const rec = registry.users.find(u => u.username === username)
|
|
|
|
|
if (!rec) { json(res, 401, { error: '用户不存在' }); return }
|
|
|
|
|
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 401, { error: '密码不正确' }); return }
|
|
|
|
|
json(res, 200, { userId: rec.id, username: rec.username, token: makeToken(rec.id) })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && route === '/api/sync') {
|
|
|
|
|
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
|
|
|
|
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
|
|
|
|
const body = await readBody(req)
|
|
|
|
|
const userData = loadUserData(auth.userId)
|
|
|
|
|
if (!userData.cursor) userData.cursor = 0
|
|
|
|
|
if (!userData.tables) userData.tables = {}
|
|
|
|
|
if (!userData.tombstones) userData.tombstones = {}
|
|
|
|
|
|
|
|
|
|
// 应用推送的变更(新者胜)
|
|
|
|
|
const incomingTables = body.tables || {}
|
|
|
|
|
for (const t of TABLES) {
|
|
|
|
|
const rows = incomingTables[t] || []
|
|
|
|
|
userData.tables[t] = userData.tables[t] || {}
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (!row || !row.uuid) continue
|
|
|
|
|
const clean = {}
|
|
|
|
|
for (const k of Object.keys(row)) if (k !== 'id') clean[k] = row[k]
|
|
|
|
|
const existing = userData.tables[t][row.uuid]
|
|
|
|
|
if (!existing || String(clean.updated_at || '') >= String(existing.updated_at || '')) {
|
|
|
|
|
userData.tables[t][row.uuid] = clean
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 应用墓碑
|
|
|
|
|
const incomingTombs = body.tombstones || []
|
|
|
|
|
for (const tb of incomingTombs) {
|
|
|
|
|
if (!TABLES.includes(tb.table) || !tb.uuid) continue
|
|
|
|
|
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
|
|
|
|
|
const prev = userData.tombstones[tb.table][tb.uuid]
|
|
|
|
|
if (!prev || tb.deleted_at > prev) userData.tombstones[tb.table][tb.uuid] = tb.deleted_at
|
|
|
|
|
}
|
|
|
|
|
// 墓碑生效:清除被删数据
|
|
|
|
|
for (const t of TABLES) {
|
|
|
|
|
const tombs = userData.tombstones[t] || {}
|
|
|
|
|
for (const [u, deletedAt] of Object.entries(tombs)) {
|
|
|
|
|
const row = userData.tables[t][u]
|
|
|
|
|
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
saveUserData(auth.userId, userData)
|
|
|
|
|
|
|
|
|
|
// 返回权威全量
|
|
|
|
|
const outTables = {}
|
|
|
|
|
const outTombs = []
|
|
|
|
|
for (const t of TABLES) {
|
|
|
|
|
outTables[t] = Object.values(userData.tables[t] || {})
|
|
|
|
|
const tombs = userData.tombstones[t] || {}
|
|
|
|
|
for (const [u, deletedAt] of Object.entries(tombs)) outTombs.push({ table: t, uuid: u, deleted_at: deletedAt })
|
|
|
|
|
}
|
|
|
|
|
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString() })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
json(res, 404, { error: 'not found' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const server = http.createServer((req, res) => {
|
|
|
|
|
handle(req, res).catch(err => {
|
|
|
|
|
json(res, 500, { error: err.message || 'server error' })
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
server.listen(PORT, () => {
|
|
|
|
|
console.log('internlog sync server listening on ' + PORT)
|
|
|
|
|
})
|