// 实习工作日志 · 多端同步服务端 // 零依赖: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') } // ---------- SSE 长连接:按用户分组,数据变更时实时通知 ---------- const sseClients = 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 }); return } // SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头) if (req.method === 'GET' && route === '/api/events') { const auth = verifyToken(url.searchParams.get('token')) if (!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/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 || {} let changed = 0 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 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 } 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) })