// 实习工作日志 · 多端同步服务端 // 零依赖: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', 'plan'] // 自然键:同一逻辑记录在两台设备上独立创建时的去重依据 const NATURAL_KEYS = { project: 'name', work_log: 'date', attendance: 'date', weekly_note: 'week_start', setting: 'key' } 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)) } // ---------- 异地备份:可选,配置环境变量后每日备份与注册表变更时自动上传 COS ---------- // COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET(形如 name-125xxxxxx) / COS_REGION(形如 ap-guangzhou) const COS_CFG = { secretId: process.env.COS_SECRET_ID || '', secretKey: process.env.COS_SECRET_KEY || '', bucket: process.env.COS_BUCKET || '', region: process.env.COS_REGION || '' } let lastOffsiteBackup = { state: 'disabled', at: '', detail: '' } function cosEncode(s) { return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()) } function cosAuth(method, pathname) { const start = Math.floor(Date.now() / 1000) - 60 const end = start + 600 const keyTime = start + ';' + end const signKey = crypto.createHmac('sha1', COS_CFG.secretKey).update(keyTime).digest('hex') const lowerPath = pathname.toLowerCase() const httpString = method.toLowerCase() + '\n' + cosEncode(lowerPath) + '\n' + '' + '\n' + 'host=' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + '\n' const stringToSign = crypto.createHash('sha1').update(httpString).digest('hex') + '\n' + signKey + '\n' + keyTime + '\n' + crypto.createHash('sha1').update('host').digest('hex') const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex') return 'q-sign-algorithm=sha1&q-ak=' + COS_CFG.secretId + '&q-sign-time=' + keyTime + '&q-key-time=' + keyTime + '&q-header-list=host&q-url-param-list=&q-signature=' + signature } async function cosPut(key, filePath) { const body = fs.readFileSync(filePath) const pathname = '/' + key.split('/').map(cosEncode).join('/') const auth = cosAuth('put', pathname) const url = 'https://' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + pathname const resp = await fetch(url, { method: 'PUT', headers: { Host: COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com', Authorization: auth, 'Content-Length': String(body.length) }, body }) if (!resp.ok) throw new Error('COS ' + resp.status + ' ' + (await resp.text()).slice(0, 120)) } async function backupOffsite() { if (!COS_CFG.secretId || !COS_CFG.secretKey || !COS_CFG.bucket || !COS_CFG.region) return try { const day = new Date().toISOString().slice(0, 10) const stamp = new Date().toISOString().replace(/[:.]/g, '-') const files = [['registry.json', 'registry.json'], ['secret.key', 'secret.key']] try { const usersDir = path.join(DATA_DIR, 'users') if (fs.existsSync(usersDir)) { for (const f of fs.readdirSync(usersDir)) if (f.endsWith('.json')) files.push(['users/' + f, 'users/' + f]) } } catch { /* 忽略 */ } for (const [rel, src] of files) { const full = path.join(DATA_DIR, src) if (!fs.existsSync(full)) continue await cosPut('daywork-backup/' + day + '/' + stamp + '/' + rel, full) await cosPut('daywork-backup/latest/' + rel, full) } lastOffsiteBackup = { state: 'ok', at: new Date().toISOString(), detail: files.length + ' files' } } catch (e) { lastOffsiteBackup = { state: 'failed', at: new Date().toISOString(), detail: (e && e.message) || String(e) } console.error('offsite backup failed:', e && e.message) } } 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: [] }) // 管理员:xionglin 固定为管理员(存量注册表自动补齐 role 字段) let adminDirty = false for (const u of registry.users) { if (u.username === 'xionglin' && u.role !== 'admin') { u.role = 'admin'; adminDirty = true } else if (u.username !== 'xionglin' && !u.role) { u.role = 'user'; adminDirty = true } } if (adminDirty) saveRegistry() function isAdminToken(token) { const auth = verifyToken(token) if (!auth || !tokenUsable(auth)) return null const rec = registry.users.find(u => u.id === auth.userId) if (!rec || rec.role !== 'admin') return null return rec } function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } } 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, iat: Date.now() })) 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 } } // 会话时效:改密 / 管理员重置密码后,签发时间早于 pwd_changed_at 的令牌一律失效 function tokenUsable(payload) { if (!payload || !payload.userId) return false const rec = registry.users.find(u => u.id === payload.userId) if (!rec) return false if (rec.pwd_changed_at) { const since = Date.parse(rec.pwd_changed_at) if (!payload.iat || payload.iat < since) return false } return true } function base64url(s) { return Buffer.from(s).toString('base64url') } // ---------- SSE 长连接:按用户分组,数据变更时实时通知 ---------- const sseClients = new Map() // 登录防爆破:IP -> { count, first }(内存态,重启即清) const loginFails = new Map() function broadcast(userId, event) { const set = sseClients.get(userId) if (!set) return for (const res of set) { try { res.write('event: ' + event + '\ndata: {}\n\n') } catch { /* 连接已断开 */ } } } function addSse(userId, res) { if (!sseClients.has(userId)) sseClients.set(userId, new Set()) sseClients.get(userId).add(res) } function removeSse(userId, res) { const set = sseClients.get(userId) if (set) { set.delete(res); if (!set.size) sseClients.delete(userId) } } // ---------- 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) }) } // ---------- 静态托管网页应用 ---------- // 容器内为 /app/dist;本机直跑时回退到仓库内的 intern-log/dist const DIST_CANDIDATES = [path.join(__dirname, 'dist'), path.join(__dirname, '..', 'intern-log', 'dist')] const DIST_DIR = process.env.DIST_DIR || DIST_CANDIDATES.find(p => fs.existsSync(path.join(p, 'index.html'))) || path.join(__dirname, 'dist') const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' } // ---------- 路由 ---------- 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 } // 非 API 的 GET 请求:返回网页应用(构建产物) if (req.method === 'GET' && !route.startsWith('/api')) { let rel = decodeURIComponent(url.pathname) if (rel === '/' || rel === '') rel = '/index.html' const file = path.normalize(path.join(DIST_DIR, rel)) if (!file.startsWith(DIST_DIR)) { json(res, 403, { error: 'forbidden' }); return } fs.readFile(file, (err, data) => { if (err) { fs.readFile(path.join(DIST_DIR, 'index.html'), (e2, d2) => { if (e2) { json(res, 404, { error: 'not found' }); return } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) res.end(d2) }) return } res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' }) res.end(data) }) return } if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return } // SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头) if (req.method === 'GET' && route === '/api/events') { const auth = verifyToken(url.searchParams.get('token')) if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' }) res.write('event: hello\ndata: {}\n\n') addSse(auth.userId, res) const ka = setInterval(() => { try { res.write(': keepalive\n\n') } catch { /* 忽略 */ } }, 25000) req.on('close', () => { clearInterval(ka); removeSse(auth.userId, res) }) return } // 修改登录密码(验证旧密码后重置盐与哈希) if (req.method === 'POST' && route === '/api/auth/password') { const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()) if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return } const body = await readBody(req) const rec = registry.users.find(u => u.id === auth.userId) if (!rec) { json(res, 404, { error: '用户不存在' }); return } if (pbkdf2Hash(String(body.oldPassword || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 400, { error: '旧密码不正确' }); return } if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '新密码至少 6 位' }); return } rec.pwd_salt = crypto.randomBytes(16).toString('hex') rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt) rec.pwd_changed_at = new Date().toISOString() // 吊销该用户全部旧令牌 saveRegistry() json(res, 200, { ok: true }) return } // 删除云端账号:注册表与全部业务数据一并清除(不可恢复,客户端已做二次确认) if (req.method === 'POST' && route === '/api/auth/delete') { const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()) if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return } const idx = registry.users.findIndex(u => u.id === auth.userId) if (idx >= 0) { registry.users.splice(idx, 1); saveRegistry() } try { fs.rmSync(userFile(auth.userId), { force: true }) } catch { /* 忽略 */ } for (const sseRes of (sseClients.get(auth.userId) || [])) { try { sseRes.end() } catch { /* 忽略 */ } } sseClients.delete(auth.userId) 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, role: username === 'xionglin' ? 'admin' : 'user', 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 loginIp = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown' const nowMs = Date.now() let st = loginFails.get(loginIp) if (st && nowMs - st.first < 10 * 60 * 1000 && st.count >= 10) { json(res, 429, { error: '尝试次数过多,请 10 分钟后再试' }); return } 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) { const s2 = loginFails.get(loginIp) loginFails.set(loginIp, !s2 || nowMs - s2.first >= 10 * 60 * 1000 ? { count: 1, first: nowMs } : { count: s2.count + 1, first: s2.first }) json(res, 401, { error: '密码不正确' }); return } loginFails.delete(loginIp) 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 || !tokenUsable(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 || {} let changed = 0 const now = new Date().toISOString() for (const t of TABLES) { const rows = incomingTables[t] || [] userData.tables[t] = userData.tables[t] || {} const nk = NATURAL_KEYS[t] || null 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] // 自然键冲突:另一 uuid 的记录已占用同一自然键 let conflictUuid = null if (nk) { const nkVal = String(clean[nk] || '') if (nkVal) { for (const u2 of Object.keys(userData.tables[t])) { if (u2 === row.uuid) continue if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break } } } } const existing = userData.tables[t][row.uuid] if (conflictUuid) { // 与自然键冲突行比较新者(而非与同 uuid 旧行) const conflictRow = userData.tables[t][conflictUuid] const incomingNewer = String(clean.updated_at || '') >= String(conflictRow.updated_at || '') if (!incomingNewer) { // 本记录较旧:加墓碑让推送端删除它 userData.tombstones[t] = userData.tombstones[t] || {} userData.tombstones[t][row.uuid] = now changed++ continue } // 本记录较新:删除冲突副本并加墓碑通知其他端 if (t === 'work_log') { // 冲突的工作日志被删时,其日志条目改挂到幸存者 for (const iu of Object.keys(userData.tables.log_item || {})) { const it = userData.tables.log_item[iu] if (String(it.wuuid || '') === conflictUuid) { it.wuuid = row.uuid; changed++ } } } delete userData.tables[t][conflictUuid] userData.tombstones[t] = userData.tombstones[t] || {} userData.tombstones[t][conflictUuid] = now changed++ } userData.tables[t][row.uuid] = clean changed++ } } // 应用墓碑 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 changed++ } } // 墓碑生效:清除被删数据 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) if (changed) broadcast(auth.userId, 'change') // 返回权威全量 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 } // ---------- 用户管理(仅管理员 xionglin) ---------- if (route.startsWith('/api/admin/users')) { const admin = isAdminToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()) if (!admin) { json(res, 403, { error: '需要管理员权限' }); return } if (req.method === 'GET' && route === '/api/admin/users') { const list = registry.users.map(u => { let size = 0 try { size = fs.statSync(userFile(u.id)).size } catch { /* 无数据文件 */ } return { id: u.id, username: u.username, role: u.role || 'user', created_at: u.created_at, data_bytes: size } }).sort((x, y) => (x.role === 'admin' ? -1 : 1) - (y.role === 'admin' ? -1 : 1)) json(res, 200, { users: list, me: admin.id }) return } if (req.method === 'POST' && route === '/api/admin/users/create') { 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, role: username === 'xionglin' ? 'admin' : 'user', pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() }) saveRegistry() json(res, 200, { ok: true, userId, username }) return } if (req.method === 'POST' && route === '/api/admin/users/password') { const body = await readBody(req) const rec = registry.users.find(u => u.id === body.userId) if (!rec) { json(res, 404, { error: '用户不存在' }); return } if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '密码至少 6 位' }); return } rec.pwd_salt = crypto.randomBytes(16).toString('hex') rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt) rec.pwd_changed_at = new Date().toISOString() saveRegistry() json(res, 200, { ok: true }) return } if (req.method === 'POST' && route === '/api/admin/users/delete') { const body = await readBody(req) if (body.userId === admin.id) { json(res, 400, { error: '不能删除自己的账号' }); return } const idx = registry.users.findIndex(u => u.id === body.userId) if (idx < 0) { json(res, 404, { error: '用户不存在' }); return } registry.users.splice(idx, 1) saveRegistry() try { fs.rmSync(userFile(body.userId), { force: true }) } catch { /* 忽略 */ } for (const sseRes of (sseClients.get(body.userId) || [])) { try { sseRes.end() } catch { /* 忽略 */ } } sseClients.delete(body.userId) json(res, 200, { ok: true }) return } } json(res, 404, { error: 'not found' }) } // ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ---------- function doBackup() { try { const day = new Date().toISOString().slice(0, 10) const dir = path.join(DATA_DIR, 'backups', day) fs.mkdirSync(path.join(dir, 'users'), { recursive: true }) try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ } try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ } try { const usersDir = path.join(DATA_DIR, 'users') if (fs.existsSync(usersDir)) { for (const f of fs.readdirSync(usersDir)) { if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f)) } } } catch { /* 忽略 */ } // 保留最近 14 天 try { const backupsRoot = path.join(DATA_DIR, 'backups') const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort() while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } } } catch { /* 忽略 */ } } catch (e) { console.error('backup failed:', e && e.message) } void backupOffsite() } doBackup() setInterval(doBackup, 24 * 60 * 60 * 1000) // ---------- HTTP 服务 ---------- 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) })