Files
daywork/server/index.js
T

675 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 实习工作日志 · 多端同步服务端
// 零依赖: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 BACKUP_DAYS = 14 // 每日快照保留天数
const WEEKLY_KEEP = 8 // 每周深快照保留份数
const TOMBSTONE_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 墓碑保留 14 天:足够覆盖离线设备,防止无限膨胀与重放
const LARGE_TOMBSTONE_BACKUP = 20 // 单次同步墓碑达到该数量时,先做数据快照再应用(大批删除的前置保险)
let lastBackupAt = ''
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', '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')
const fd = fs.openSync(tmp, 'w')
try {
fs.writeSync(fd, JSON.stringify(data))
fs.fsyncSync(fd) // 落盘后再原子改名:进程崩溃/断电不会留下半截文件
} finally {
fs.closeSync(fd)
}
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 emptyUserData(userId) {
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
}
// 表结构自愈:tables[t] 必须是「uuid -> 行对象」;
// 兼容历史误存为数组的形态(并剔除 null / 非法行),避免自然键扫描在 null 上崩溃、避免序列化丢弃行
function sanitizeTables(data) {
let healed = false
const src = data.tables && typeof data.tables === 'object' && !Array.isArray(data.tables) ? data.tables : null
if (!src) { data.tables = {}; return true }
const out = {}
for (const t of Object.keys(src)) {
const container = src[t]
if (Array.isArray(container)) {
healed = true
const m = {}
for (const row of container) {
if (row && typeof row === 'object' && row.uuid) m[row.uuid] = row
}
out[t] = m
continue
}
if (!container || typeof container !== 'object') { healed = true; out[t] = {}; continue }
const m = {}
for (const k of Object.keys(container)) {
const row = container[k]
if (!row || typeof row !== 'object' || Array.isArray(row) || !row.uuid) { healed = true; continue }
m[k] = row
}
out[t] = m
}
data.tables = out
if (!data.tombstones || typeof data.tombstones !== 'object' || Array.isArray(data.tombstones)) { data.tombstones = {}; healed = true }
for (const t of Object.keys(data.tombstones)) {
const bucket = data.tombstones[t]
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) { delete data.tombstones[t]; healed = true; continue }
for (const k of Object.keys(bucket)) {
if (!bucket[k]) { delete bucket[k]; healed = true }
}
}
return healed
}
function loadUserData(userId) {
const full = userFile(userId)
if (fs.existsSync(full)) {
try {
const data = JSON.parse(fs.readFileSync(full, 'utf-8'))
const healed = sanitizeTables(data)
return { data, healed }
} catch (e) {
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
return { data: emptyUserData(userId), healed: false }
}
}
return { data: emptyUserData(userId), healed: false }
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
function gcTombstones(data) {
const cutoff = Date.now() - TOMBSTONE_TTL_MS
let pruned = 0
for (const t of Object.keys(data.tombstones || {})) {
const bucket = data.tombstones[t]
for (const u of Object.keys(bucket)) {
const ts = Date.parse(bucket[u])
if (Number.isFinite(ts) && ts < cutoff) { delete bucket[u]; pruned++ }
}
if (!Object.keys(bucket).length) delete data.tombstones[t]
}
return pruned
}
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,
users: registry.users.length,
lastBackup: lastBackupAt
})
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 loaded = loadUserData(auth.userId)
const userData = loaded.data
let dirty = loaded.healed
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
if (!userData.tables) { userData.tables = {}; dirty = true }
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
if (!userData.created_at) { userData.created_at = new Date().toISOString(); dirty = true } // 数据纪元(客户端灾难自检依据)
// 应用推送的变更(新者胜 + 自然键去重)
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
const other = userData.tables[t][u2]
if (!other || typeof other !== 'object') continue
if (String(other[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++
}
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑
const incomingTombs = body.tombstones || []
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
}
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 && typeof row === 'object' && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
}
}
if (gcTombstones(userData) > 0) dirty = true
if (dirty || changed > 0) 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(), dataEpoch: String(userData.created_at || '') })
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 snapshotTo(dir) {
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') && !f.includes('.corrupt-')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
}
function pruneBackups() {
try {
const root = path.join(DATA_DIR, 'backups')
const entries = fs.readdirSync(root).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
const days = Array.from(new Set(entries.map(d => d.slice(0, 10)))).sort()
while (days.length > BACKUP_DAYS) {
const dead = days.shift()
for (const d of entries) {
if (d.startsWith(dead)) { try { fs.rmSync(path.join(root, d), { recursive: true, force: true }) } catch { /* 忽略 */ } }
}
}
} catch { /* 忽略 */ }
}
function doBackup() {
try {
const now = new Date()
const day = now.toISOString().slice(0, 10)
const root = path.join(DATA_DIR, 'backups')
let dir = path.join(root, day)
// 当日首份快照不可变(当日完整证据链);之后的同日备份落时间戳副本
if (fs.existsSync(dir)) dir = path.join(root, day + '_t' + now.toTimeString().slice(0, 8).replace(/:/g, ''))
snapshotTo(dir)
// 每周一额外做周快照(更深恢复点),保留 8 份
if (now.getDay() === 1) {
const wdir = path.join(root, 'weekly', day)
if (!fs.existsSync(wdir)) snapshotTo(wdir)
try {
const wroot = path.join(root, 'weekly')
const ws = fs.readdirSync(wroot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (ws.length > WEEKLY_KEEP) { const old = ws.shift(); try { fs.rmSync(path.join(wroot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
}
pruneBackups()
lastBackupAt = new Date().toISOString()
} 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 => {
console.error('[api] ' + req.method + ' ' + req.url + ' -> 500: ' + ((err && (err.stack || err.message)) || String(err)))
json(res, 500, { error: (err && err.message) || 'server error' })
})
})
server.listen(PORT, () => {
console.log('internlog sync server listening on ' + PORT)
})