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

583 lines
30 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 || '').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/<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)');
}
// 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) ? '(已托管)' : '(未部署前端)');
});