Files
daywork/server/index.js
T

331 lines
13 KiB
JavaScript

// 实习工作日志 · 多端同步服务端
// 零依赖: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']
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
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))
}
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
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
}
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)
})