Files
daywork/server/index.js
T

207 lines
7.9 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']
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')
}
// ---------- 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)
})
}
// ---------- 路由 ----------
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 }
if (req.method === 'GET' && route === '/api/health') { 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, 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 || {}
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
}
}
}
// 应用墓碑
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
}
// 墓碑生效:清除被删数据
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)
// 返回权威全量
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)
})