551 lines
24 KiB
JavaScript
551 lines
24 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', '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')
|
||
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) {
|
||
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
|
||
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救
|
||
if (!data.created_at) data.created_at = new Date().toISOString()
|
||
return data
|
||
}
|
||
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(), 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 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)
|
||
})
|