// Footprints 自建同步 + Web 服务(零依赖:node:http + node:sqlite) import http from 'node:http'; import fs from 'node:fs'; import zlib from 'node:zlib'; // 图床网关:Lsky Pro(已对接 COS),附件直传对象存储,应用服务器不做搬运工 const LSKY_API = (process.env.LSKY_API || '').trim().replace(/\/$/, ''); const LSKY_TOKEN = (process.env.LSKY_TOKEN || '').trim().replace(/^Bearer\s+/i, ''); const LSKY_STRATEGY_ID = process.env.LSKY_STRATEGY_ID || ''; // 存储策略(如 2=COS),不填用默认策略 function sanitizeImageUrl(u) { // 裸 IP:port 的 http 地址在 HTTPS 站点会被拦截;域名地址统一切 https if (!u) return u; return /^http:\/\/\d+\./.test(u) ? u : u.replace(/^http:\/\//, 'https://'); } async function lskyUpload(buf, fileName, mime) { const fd = new FormData(); fd.append('file', new Blob([buf], { type: mime || 'application/octet-stream' }), fileName); if (LSKY_STRATEGY_ID) fd.append('strategy_id', LSKY_STRATEGY_ID); const r = await fetch(LSKY_API + '/upload', { method: 'POST', headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' }, body: fd, signal: AbortSignal.timeout(60_000), }); const j = await r.json().catch(() => null); const url = sanitizeImageUrl(j?.data?.links?.url || j?.data?.url); if (!j || j.status !== true || !url) throw new Error('LSKY ' + r.status + ': ' + (j?.message || 'no url')); return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key }; } async function deleteUserCascade(db, target) { // 图床远端:附件 remote keys + 用户资产(头像等) let remote = 0; if (LSKY_API && LSKY_TOKEN) { try { const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all(); for (const a of atts) { for (const k of [a.remote_key, a.remote_thumb_key]) { if (k) { try { await lskyDelete(k); remote++; } catch {} } } } } catch {} try { const assets = db.prepare('SELECT key FROM user_assets WHERE user_id=?').all(target.id); for (const a of assets) { try { await lskyDelete(a.key); remote++; } catch {} } } catch {} } try { db.prepare('DELETE FROM user_assets WHERE user_id=?').run(target.id); } catch {} try { commandsFor(target.id).close(); } catch {} userCommands.delete(target.id); if (demoUserId === target.id) demoUserId = undefined; for (const f of [target.id + '.db', target.id + '.db-wal', target.id + '.db-shm']) { try { fs.rmSync(path.join(USERS_DIR, f), { force: true }); } catch {} } try { fs.rmSync(path.join(USERS_DIR, String(target.id)), { recursive: true, force: true }); } catch {} db.exec('BEGIN'); try { db.prepare('DELETE FROM tokens WHERE user_id=?').run(target.id); db.prepare('DELETE FROM current WHERE user_id=?').run(target.id); db.prepare('DELETE FROM attachments WHERE user_id=?').run(target.id); db.prepare('DELETE FROM devices WHERE user_id=?').run(target.id); db.prepare('DELETE FROM users WHERE id=?').run(target.id); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } return remote; } async function lskyDelete(key) { const r = await fetch(LSKY_API + '/images/' + encodeURIComponent(key), { method: 'DELETE', headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' }, signal: AbortSignal.timeout(30_000), }); const j = await r.json().catch(() => null); if (!j || j.status !== true) throw new Error('LSKY delete ' + r.status + ': ' + (j?.message || '')); return true; } // 历史 URL 反查 key:按 pathname 在图床相册里匹配(最多翻 5 页) async function lskyFindKeyByUrl(url) { try { const pathname = new URL(url).pathname.replace(/^\/i\//, ''); for (let page = 1; page <= 5; page++) { const r = await fetch(LSKY_API + '/images?per_page=100&page=' + page, { headers: { authorization: 'Bearer ' + LSKY_TOKEN, accept: 'application/json' }, signal: AbortSignal.timeout(30_000), }); const j = await r.json().catch(() => null); const items = j?.data?.data || []; const hit = items.find(x => x.pathname === pathname); if (hit) return hit.key; if (page >= (j?.data?.last_page || 1)) break; } } catch (e) { console.log('[lsky] 反查 key 失败:', e.message); } return null; } // 删除联动:记录/附件删除后清理图床(后台异步) function lskyCleanupRemote(items) { if (!LSKY_API || !LSKY_TOKEN) return; (async () => { for (const it of items) { try { let k = it.remote_key; if (!k && it.remote_url) k = await lskyFindKeyByUrl(it.remote_url); if (k) await lskyDelete(k); let tk = it.remote_thumb_key; if (!tk && it.remote_thumb) tk = await lskyFindKeyByUrl(it.remote_thumb); if (tk) await lskyDelete(tk); if (k || tk) console.log('[lsky] 图床已清理:', it.file_name || it.id); } catch (e) { console.log('[lsky] 图床清理失败:', it.file_name || it.id, e.message); } } })(); } import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getDb } from './db.mjs'; import { createAppCommands } from './app-commands.mjs'; import { register, login, authUser, isUserAdmin } from './auth.mjs'; import crypto from 'node:crypto'; import { push, pull } from './sync.mjs'; import { upload, download } from './attachments.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 8788); const WWW_DIR = process.env.WWW_DIR || path.join(__dirname, '..', 'www'); const USERS_DIR = path.join(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'users'); const REGISTER_ENABLED = (process.env.REGISTER_ENABLED ?? 'true') === 'true'; const MAX_BODY = 160 * 1024 * 1024; // 160MB const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }; // 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库) const userCommands = new Map(); const gzipCache = new Map(); // 指纹资产 gzip 预压缩缓存 let demoUserId; function commandsFor(userId) { let c = userCommands.get(userId); if (!c) { fs.mkdirSync(USERS_DIR, { recursive: true }); c = createAppCommands(path.join(USERS_DIR, userId + '.db')); userCommands.set(userId, c); // demo 用户:档案库首次打开时播种演示数据(demo_seed 幂等,失败不影响请求) try { if (demoUserId === undefined) { demoUserId = getDb().prepare('SELECT id FROM users WHERE username=?').get('demo')?.id ?? null; } if (userId === demoUserId) { const r = c.handleCommand('demo_seed', {}); console.log('[footprints-sync] demo 演示数据:', JSON.stringify(r)); } } catch (e) { console.log('[footprints-sync] demo 数据播种跳过:', e.message); } } return c; } const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); const route = url.pathname; const send = (code, ok, data) => { let body = Buffer.from(JSON.stringify(ok ? { ok: true, data } : { ok: false, error: data })); const headers = { 'Content-Type': 'application/json; charset=utf-8' }; // API JSON gzip:附件 base64 / 列表大负载体积降 70%+ if (body.length >= 1024 && /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) { body = zlib.gzipSync(body, { level: 6 }); headers['Content-Encoding'] = 'gzip'; headers.Vary = 'Accept-Encoding'; } res.writeHead(code, headers); res.end(body); }; res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } try { if (req.method === 'GET' && route === '/health') { getDb(); return send(200, true, { status: 'ok', service: 'footprints-sync', time: new Date().toISOString() }); } // ---- 认证 ---- if (req.method === 'POST' && (route === '/auth/register' || route === '/auth/login')) { const body = await readJson(req); if (route === '/auth/register') { if (!REGISTER_ENABLED) return send(403, false, '注册已关闭'); return send(200, true, register(body)); } return send(200, true, login(body)); } // ---- 管理(仅 is_admin 登录会话) ---- if (route === '/admin/users' || route === '/admin/users/delete' || route === '/admin/demo-rebuild' || route === '/admin/invites' || route === '/admin/demo-seed' || route === '/admin/attachments/sync-lsky' || route.startsWith('/admin/invites/')) { let auth; try { auth = authUser(req); } catch (e) { return send(e.code || 401, false, e.message); } if (!isUserAdmin(auth.user_id)) return send(403, false, '需要管理员账号'); const db = getDb(); if (req.method === 'GET' && route === '/admin/users') { const users = db.prepare(` SELECT u.id, u.username, u.is_admin, u.created_at, (SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices, (SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records, (SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments, (SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes FROM users u ORDER BY u.created_at`).all() .map(u => ({ ...u, is_admin: !!u.is_admin })); return send(200, true, { total: users.length, users }); } // 用户删除:高危操作,级联清理(图床文件 → 用户库 → 附件目录 → 认证数据) if (req.method === 'POST' && route === '/admin/users/delete') { const body = await readJson(req); const uid = String(body.user_id ?? '').trim(); const target = db.prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(uid); if (!target) return send(404, false, '用户不存在'); if (target.id === auth.user_id) return send(400, false, '不能删除当前登录的账号自己'); if (target.is_admin && db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get().c <= 1) { return send(400, false, '不能删除最后一位管理员'); } const remote = await deleteUserCascade(db, target); console.log('[footprints-sync] 用户已删除:', target.username, '(图床文件 ' + remote + ' 个)'); return send(200, true, { ok: true, username: target.username, remote_deleted: remote }); } // 重建演示账号:级联删除现有 demo → 重新注册 → 重新播种 if (req.method === 'POST' && route === '/admin/demo-rebuild') { const existing = db.prepare('SELECT id, username FROM users WHERE username=?').get('demo'); if (existing && existing.id !== auth.user_id) await deleteUserCascade(db, existing); const { register } = await import('./auth.mjs'); const uid = register({ username: 'demo', password: 'demo1234', device_name: 'seed-demo', seed: true }).user_id; db.prepare('UPDATE users SET is_admin=0 WHERE username=?').run('demo'); const seeded = commandsFor(uid).handleCommand('demo_seed', {}); console.log('[footprints-sync] demo 账号已重建:', JSON.stringify(seeded)); return send(200, true, { ok: true, seeded }); } if (req.method === 'GET' && route === '/admin/invites') { const invites = db.prepare(` SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid, u.username AS used_by_name, cu.username AS created_by_name FROM invite_codes i LEFT JOIN users u ON u.id = i.used_by LEFT JOIN users cu ON cu.id = i.created_by ORDER BY i.id DESC`).all() .map(i => ({ ...i, valid: !!i.valid })); return send(200, true, { total: invites.length, invites }); } if (req.method === 'POST' && route === '/admin/invites/generate') { const body = await readJson(req); const count = Math.min(50, Math.max(1, Number(body.count) || 1)); const codes = []; const ins = db.prepare('INSERT INTO invite_codes (code, created_by) VALUES (?,?)'); for (let i = 0; i < count; i++) { const code = 'FP-' + crypto.randomBytes(5).toString('hex').toUpperCase(); ins.run(code, auth.user_id); codes.push(code); } return send(200, true, { codes }); } if (req.method === 'POST' && route === '/admin/invites/revoke') { const body = await readJson(req); const r = db.prepare('UPDATE invite_codes SET valid=0 WHERE id=?').run(Number(body.id)); return send(200, true, { revoked: r }); } if (req.method === 'POST' && route === '/admin/invites/create') { const body = await readJson(req); const code = String(body.code || '').trim().toUpperCase(); if (!/^[A-Z0-9_-]{4,32}$/.test(code)) return send(400, false, '邀请码需为 4-32 位字母、数字、- 或 _'); const remark = String(body.remark || '').trim().slice(0, 64) || null; try { db.prepare('INSERT INTO invite_codes (code, remark, created_by) VALUES (?,?,?)').run(code, remark, auth.user_id); } catch (e) { return send(400, false, String(e.message).includes('UNIQUE') ? ('邀请码已存在:' + code) : (e.message || String(e))); } return send(200, true, { code }); } if (req.method === 'POST' && route === '/admin/invites/update') { const body = await readJson(req); const row = db.prepare('SELECT id, used_by FROM invite_codes WHERE id=?').get(Number(body.id)); if (!row) return send(404, false, '邀请码不存在'); const sets = []; const args = []; if (typeof body.remark === 'string') { sets.push('remark=?'); args.push(body.remark.trim().slice(0, 64) || null); } if (typeof body.valid === 'boolean') { if (body.valid && row.used_by) return send(400, false, '已使用的邀请码不可启用'); sets.push('valid=?'); args.push(body.valid ? 1 : 0); } if (!sets.length) return send(400, false, '没有需要更新的字段'); args.push(row.id); db.prepare('UPDATE invite_codes SET ' + sets.join(', ') + ' WHERE id=?').run(...args); return send(200, true, { id: row.id }); } if (req.method === 'POST' && route === '/admin/invites/delete') { const body = await readJson(req); const r = db.prepare('DELETE FROM invite_codes WHERE id=?').run(Number(body.id)); if (!r.changes) return send(404, false, '邀请码不存在'); return send(200, true, { deleted: r.changes }); } if (req.method === 'POST' && route === '/admin/attachments/sync-lsky') { // 存量迁移:本用户所有未上图的附件推送到 Lsky(逐个,失败跳过) if (!LSKY_API || !LSKY_TOKEN) return send(400, false, '未配置 LSKY_API/LSKY_TOKEN'); const cmds = commandsFor(auth.user_id); const rows = cmds.getDb().prepare("SELECT id, file_name, mime FROM attachment WHERE owner_type='footprint' AND deleted_at IS NULL AND (remote_url IS NULL OR remote_url='')").all(); let done = 0, failed = 0; for (const row of rows) { try { const orig = cmds.handleCommand('attachment_read', { id: row.id }); const up = await lskyUpload(Buffer.from(orig.data_b64, 'base64'), row.file_name, row.mime); let thumbUrl = null; if (row.mime?.startsWith('image/')) { const td = cmds.handleCommand('attachment_thumb', { id: row.id }); const tUp = await lskyUpload(Buffer.from(td.data_b64, 'base64'), 'thumb-' + row.file_name + '.webp', td.mime || 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; } cmds.handleCommand('attachment_set_remote', { id: row.id, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); done++; } catch (e) { failed++; console.log('[lsky-migrate]', row.id, e.message); } } return send(200, true, { total: rows.length, done, failed }); } if (req.method === 'POST' && route === '/admin/demo-seed') { const demoRow = db.prepare('SELECT id FROM users WHERE username=?').get('demo'); if (!demoRow) return send(404, false, 'demo 账号不存在'); try { const r = commandsFor(demoRow.id).handleCommand('demo_seed', {}); return send(200, true, r); } catch (e) { return send(500, false, 'demo_seed 失败: ' + (e.message || String(e))); } } return send(404, false, 'not found'); } // ---- 同步与 Web API(鉴权区) ---- if (route.startsWith('/sync/') || route.startsWith('/api/') || route.startsWith('/auth/')) { let auth; try { auth = authUser(req); } catch (e) { return send(e.code || 401, false, e.message); } if (req.method === 'GET' && route === '/auth/me') { const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id); return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id }); } if (req.method === 'POST' && route === '/sync/push') { const body = await readJson(req); // wire 变更直接应用到该用户档案库(网页版立即可见);generate_changes 由档案库 oplog 驱动 return send(200, true, commandsFor(auth.user_id).applyWireBatch(body.changes)); } if (req.method === 'GET' && route === '/sync/pull') { return send(200, true, commandsFor(auth.user_id).pullWire(url.searchParams.get('cursor'), url.searchParams.get('limit'))); } if (req.method === 'POST' && route === '/sync/attachment') { const body = await readJson(req); return send(200, true, upload(auth.user_id, body)); } if (req.method === 'GET' && route.startsWith('/sync/attachment/')) { const sha = route.slice('/sync/attachment/'.length); const file = download(auth.user_id, sha); if (!file) return send(404, false, '附件不存在'); res.writeHead(200, { 'Content-Type': file.mime || 'application/octet-stream', 'Content-Length': file.buf.length, // 内容寻址 = 不可变:浏览器一年缓存(private:不进共享缓存,杜绝跨账号串缓存) 'Cache-Control': 'private, max-age=31536000, immutable' }); return res.end(file.buf); } if (req.method === 'GET' && route === '/sync/status') { const db = getDb(); const changes = db.prepare('SELECT COUNT(*) AS c FROM changes WHERE user_id=?').get(auth.user_id).c; const att = db.prepare('SELECT COUNT(*) AS c, COALESCE(SUM(size),0) AS bytes FROM attachments WHERE user_id=?').get(auth.user_id); return send(200, true, { user_id: auth.user_id, device_id: auth.device_id, total_changes: changes, attachments: att.c, attachment_bytes: att.bytes }); } // 个人信息证件照:经图床网关直传 COS,返回访问 URL if (req.method === 'POST' && route === '/api/profile_photo') { const body = await readJson(req); if (!LSKY_API || !LSKY_TOKEN) return send(400, false, '未配置图床,无法上传证件照'); try { const buf = Buffer.from(body.data_b64 || '', 'base64'); if (!buf.length) return send(400, false, '空文件'); const up = await lskyUpload(buf, body.file_name || 'profile-photo.png', body.mime || 'image/png'); try { getDb().prepare('INSERT OR REPLACE INTO user_assets (user_id, kind, key, url) VALUES (?,?,?,?)') .run(auth.user_id, 'photo', up.key || sanitizeImageUrl(up.url), sanitizeImageUrl(up.url)); } catch {} return send(200, true, { url: sanitizeImageUrl(up.url) }); } catch (e) { return send(500, false, '证件照上传失败: ' + e.message); } } // 附件上传(原始二进制 PUT):无 base64 膨胀;图床上传改为后台异步,保存立即返回 if (req.method === 'PUT' && route === '/sync/attachment/raw') { const q = url.searchParams; const name = (q.get('name') || 'file').slice(0, 200); const mime = (q.get('mime') || 'application/octet-stream').slice(0, 120); const ownerId = Number(q.get('owner') || 0); if (!ownerId) return send(400, false, '缺少 owner'); const chunks = []; for await (const c of req) chunks.push(c); const buf = Buffer.concat(chunks); if (!buf.length) return send(400, false, '空请求体'); if (buf.length > 80 * 1024 * 1024) return send(400, false, '单个文件不超过 80MB'); const thumbB64 = String(q.get('thumb') || ''); const data = commandsFor(auth.user_id).handleCommand('attachment_add', { owner_id: ownerId, file_name: name, mime, size: buf.length, data_b64: buf.toString('base64') }); const cmds = commandsFor(auth.user_id); const itemId = data.item.id; // 缩略图回填(带 base64,跟随本响应返回后由客户端二次调用亦可;此处直接从 query 收) // 图床异步直传:失败仅记日志,可用 /admin/attachments/sync-lsky 补传 if (LSKY_API && LSKY_TOKEN) { (async () => { try { const up = await lskyUpload(buf, name, mime); let thumbUrl = null, thumbKey = null; if (thumbB64) { const tUp = await lskyUpload(Buffer.from(thumbB64, 'base64'), 'thumb-' + name + '.webp', 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; thumbKey = tUp.key; } cmds.handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); console.log('[lsky] 附件 #' + itemId + ' 已上图床'); } catch (e) { console.log('[lsky] 附件 #' + itemId + ' 后台上传失败(可用 sync-lsky 补传):', e.message); } })(); } return send(200, true, data); } // 附件下载(带原始文件名的 Content-Disposition,浏览器直接落盘) if (req.method === 'POST' && route === '/api/attachment_download') { const body = await readJson(req); const data = commandsFor(auth.user_id).handleCommand('attachment_read', { id: Number(body.id) }); const buf = Buffer.from(data.data_b64, 'base64'); res.writeHead(200, { 'Content-Type': data.mime || 'application/octet-stream', 'Content-Length': buf.length, 'Content-Disposition': "attachment; filename*=UTF-8''" + encodeURIComponent(data.file_name || 'file') }); return res.end(buf); } // 附件删除:本地清理 + 图床异步清理 if (req.method === 'POST' && route === '/api/attachment_delete') { const body = await readJson(req); const cmds = commandsFor(auth.user_id); const row = cmds.getDb().prepare('SELECT id, file_name, remote_url, remote_thumb, remote_key, remote_thumb_key FROM attachment WHERE id=?').get(Number(body.id)); const data = cmds.handleCommand('attachment_delete', body); if (row && (row.remote_key || row.remote_url || row.remote_thumb)) lskyCleanupRemote([row]); return send(200, true, data); } // 记录删除:级联清理附件 + 图床 if (req.method === 'POST' && route === '/api/footprint_delete') { const body = await readJson(req); const cmds = commandsFor(auth.user_id); const atts = cmds.getDb().prepare("SELECT id, file_name, remote_url, remote_thumb, remote_key, remote_thumb_key FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL AND (remote_url IS NOT NULL OR remote_key IS NOT NULL)").all(Number(body.id)); const data = cmds.handleCommand('footprint_delete', body); if (atts.length) lskyCleanupRemote(atts); return send(200, true, data); } // 附件直传图床(兼容旧 base64 通道):同样异步 if (req.method === 'POST' && route === '/api/attachment_add') { const body = await readJson(req); const data = commandsFor(auth.user_id).handleCommand('attachment_add', body); if (LSKY_API && LSKY_TOKEN) { const orig = Buffer.from(body.data_b64 || '', 'base64'); const itemId = data.item.id; (async () => { try { const up = await lskyUpload(orig, body.file_name || ('file-' + itemId), body.mime); let thumbUrl = null, thumbKey = null; if (body.thumb_b64) { const tUp = await lskyUpload(Buffer.from(body.thumb_b64, 'base64'), 'thumb-' + (body.file_name || 'img') + '.webp', 'image/webp'); thumbUrl = tUp.thumb_url || tUp.url; thumbKey = tUp.key; } commandsFor(auth.user_id).handleCommand('attachment_set_remote', { id: itemId, remote_url: up.url, remote_thumb: thumbUrl, remote_key: up.key, remote_thumb_key: thumbKey }); console.log('[lsky] 附件 #' + itemId + ' 已上图床'); } catch (e) { console.log('[lsky] 附件 #' + itemId + ' 后台上传失败:', e.message); } })(); } return send(200, true, data); } // Web 应用命令层:/api/(与桌面端同一契约,按用户隔离的档案库) if (req.method === 'POST' && route.startsWith('/api/')) { const cmd = route.slice(5); if (!cmd || cmd.includes('/')) return send(404, false, 'not found'); const body = await readJson(req); const data = commandsFor(auth.user_id).handleCommand(cmd, body); return send(200, true, data); } } // ---- 静态前端(www/,指纹资源长缓存,index 禁缓存)---- if (req.method === 'GET' && fs.existsSync(WWW_DIR)) { let p = route === '/' ? '/index.html' : decodeURIComponent(route); const abs = path.join(WWW_DIR, path.normalize(p).replace(/^([.][.][\\/])+/, '')); if (abs.startsWith(WWW_DIR) && fs.existsSync(abs) && fs.statSync(abs).isFile()) { const headers = { 'Content-Type': MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream', 'Cache-Control': p.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache' }; // 指纹资产预压缩缓存(首次 gzip 后驻留内存,后续零 CPU) if (p.startsWith('/assets/') && /\.(js|css|svg|json)$/.test(p) && /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) { let gz = gzipCache.get(abs); if (!gz) { gz = zlib.gzipSync(fs.readFileSync(abs), { level: 6 }); gzipCache.set(abs, gz); } headers['Content-Encoding'] = 'gzip'; headers['Content-Length'] = gz.length; headers.Vary = 'Accept-Encoding'; res.writeHead(200, headers); return res.end(gz); } res.writeHead(200, headers); return res.end(fs.readFileSync(abs)); } const indexHtml = path.join(WWW_DIR, 'index.html'); if (fs.existsSync(indexHtml)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }); return res.end(fs.readFileSync(indexHtml)); } } return send(404, false, 'not found'); } catch (err) { return send(400, false, err.message || String(err)); } }); function readJson(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', chunk => { size += chunk.length; if (size > MAX_BODY) { reject(new Error('请求体过大')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); } catch { reject(new Error('JSON 解析失败')); } }); req.on('error', reject); }); } getDb(); // 种子演示账号(demo / demo1234)+ 演示数据;seed 标记绕过邀请码/名额校验 try { const d = getDb(); const existing = d.prepare('SELECT id FROM users WHERE username=?').get('demo'); let demoId = existing?.id; if (!demoId) { const { register } = await import('./auth.mjs'); demoId = register({ username: 'demo', password: 'demo1234', device_name: 'seed-demo', seed: true }).user_id; console.log('[footprints-sync] demo 账号已创建 (demo / demo1234)'); } else { console.log('[footprints-sync] demo 账号已存在 (demo / demo1234)'); } // demo 永远不是管理员(历史库也一并纠正) d.prepare('UPDATE users SET is_admin=0 WHERE username=?').run('demo'); const seeded = commandsFor(demoId).handleCommand('demo_seed', {}); console.log('[footprints-sync] demo 演示数据:', JSON.stringify(seeded)); } catch (e) { console.log('[footprints-sync] demo 种子跳过:', e.message); } server.listen(PORT, '0.0.0.0', () => { console.log('[footprints-sync] listening on 0.0.0.0:' + PORT); console.log('[footprints-sync] data dir:', process.env.DATA_DIR || path.join(__dirname, '..', 'data')); console.log('[footprints-sync] web dir:', WWW_DIR, fs.existsSync(WWW_DIR) ? '(已托管)' : '(未部署前端)'); });