331 lines
13 KiB
JavaScript
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)
|
|
})
|