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