// 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 || '').replace(/\/$/, ''); const LSKY_TOKEN = process.env.LSKY_TOKEN || ''; 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 }; } 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/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 === '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 }); 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 }); } // 附件直传图床:attachment_add 落本地后同步推 Lsky(COS),失败自动回退本地 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) { try { const orig = Buffer.from(body.data_b64 || '', 'base64'); const up = await lskyUpload(orig, body.file_name || ('file-' + data.item.id), body.mime); let thumbUrl = 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; } commandsFor(auth.user_id).handleCommand('attachment_set_remote', { id: data.item.id, remote_url: up.url, remote_thumb: thumbUrl }); data.item.remote_url = up.url; data.item.remote_thumb = thumbUrl; } catch (e) { console.log('[lsky] 直传失败,保留本地存储:', 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)'); } 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) ? '(已托管)' : '(未部署前端)'); });