Files
FootPrint/sync-server/src/index.mjs
T

383 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/<command>(与桌面端同一契约,按用户隔离的档案库)
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) ? '(已托管)' : '(未部署前端)');
});