From 216d18c8437167599e74517eb1c0e0d5828292c8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 17 Oct 2026 14:59:23 +0800 Subject: [PATCH] fix: lazy DATA_DIR in sync db (multi-instance safe), demo never admin, first real user auto-promoted --- scripts/test-integration.mjs | 61 +++++++++++++++++++++--------------- sync-server/src/auth.mjs | 8 +++-- sync-server/src/db.mjs | 19 +++++++---- sync-server/src/index.mjs | 3 ++ 4 files changed, 57 insertions(+), 34 deletions(-) diff --git a/scripts/test-integration.mjs b/scripts/test-integration.mjs index b749518..775ff23 100644 --- a/scripts/test-integration.mjs +++ b/scripts/test-integration.mjs @@ -43,47 +43,55 @@ const A = await startServer(8891, 1); const demoLogin = await api(A, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(demoLogin.status === 200 && demoLogin.body?.data?.token, 'demo 登录', demoLogin.body); -const T = demoLogin.body.data.token; +const D = demoLogin.body.data.token; +// 首个真实用户 root 自动成为管理员(demo 不占席位) +const rootReg = await api(A, 'POST', '/auth/register', { username: 'root', password: 'password123', invite_code: INVITE }); +ok(rootReg.status === 200, '首个真实用户 root 注册', rootReg.body); +const rootLogin = await api(A, 'POST', '/auth/login', { username: 'root', password: 'password123' }); +ok(rootLogin.status === 200, 'root 登录', rootLogin.body); +const T = rootLogin.body.data.token; -const me = await api(A, 'GET', '/auth/me', undefined, T); -ok(me.body?.data?.username === 'demo' && me.body?.data?.is_admin === true, '/auth/me 返回 demo 且是管理员', me.body); +const me = await api(A, 'GET', '/auth/me', undefined, D); +ok(me.body?.data?.username === 'demo' && me.body?.data?.is_admin === false, 'demo 不是管理员(演示账号不占席位)', me.body); +const rootMe = await api(A, 'GET', '/auth/me', undefined, T); +ok(rootMe.body?.data?.username === 'root' && rootMe.body?.data?.is_admin === true, 'root 是管理员', rootMe.body); -const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T); +const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D); ok(fp.body?.data?.total === 16, '演示足迹 16 条(播种成功)', fp.body?.data?.total); -const grades = await api(A, 'POST', '/api/grades_overview', {}, T); +const grades = await api(A, 'POST', '/api/grades_overview', {}, D); ok(grades.body?.data?.semesters?.length === 4, '演示学期 4 个', grades.body?.data?.semesters?.length); -const tl = await api(A, 'POST', '/api/timeline', { query: {} }, T); +const tl = await api(A, 'POST', '/api/timeline', { query: {} }, D); ok((tl.body?.data?.groups?.length ?? 0) >= 1, 'timeline 有分组'); -const st = await api(A, 'POST', '/api/stats_overview', {}, T); +const st = await api(A, 'POST', '/api/stats_overview', {}, D); ok(st.body?.ok === true, 'stats_overview 可用'); // 附件缩略图:上传带 thumb → 列表标记 → thumb/原图分离读取 -const tl0 = await api(A, 'POST', '/api/type_list', {}, T); -const fpUp = await api(A, 'POST', '/api/footprint_upsert', { item: { type_id: (tl0.body?.data?.items || [])[0]?.id, title: '附件缩略图测试', status: 'finished' } }, T); +const tl0 = await api(A, 'POST', '/api/type_list', {}, D); +const fpUp = await api(A, 'POST', '/api/footprint_upsert', { item: { type_id: (tl0.body?.data?.items || [])[0]?.id, title: '附件缩略图测试', status: 'finished' } }, D); const fpId = fpUp.body?.data?.id; ok(!!fpId, '创建附件测试足迹', fpUp.body); -const attAdd = await api(A, 'POST', '/api/attachment_add', { owner_id: fpId, file_name: 'photo.jpg', mime: 'image/jpeg', size: 8, data_b64: Buffer.from('ORIGDATA').toString('base64'), thumb_b64: Buffer.from('THUMBWEBPDATA').toString('base64') }, T); +const attAdd = await api(A, 'POST', '/api/attachment_add', { owner_id: fpId, file_name: 'photo.jpg', mime: 'image/jpeg', size: 8, data_b64: Buffer.from('ORIGDATA').toString('base64'), thumb_b64: Buffer.from('THUMBWEBPDATA').toString('base64') }, D); ok(attAdd.body?.ok === true && !!attAdd.body?.data?.item?.thumb_rel_path, 'attachment_add 存储缩略图', attAdd.body); -const attList = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, T); +const attList = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, D); ok(!!attList.body?.data?.items?.[0]?.thumb_rel_path, 'attachment_list 返回缩略图标记'); -const attThumb = await api(A, 'POST', '/api/attachment_thumb', { id: attAdd.body?.data?.item?.id }, T); +const attThumb = await api(A, 'POST', '/api/attachment_thumb', { id: attAdd.body?.data?.item?.id }, D); ok(attThumb.body?.data?.data_b64 === Buffer.from('THUMBWEBPDATA').toString('base64'), 'attachment_thumb 返回缩略图内容', attThumb.body); -const attRead2 = await api(A, 'POST', '/api/attachment_read', { id: attAdd.body?.data?.item?.id }, T); +const attRead2 = await api(A, 'POST', '/api/attachment_read', { id: attAdd.body?.data?.item?.id }, D); ok(attRead2.body?.data?.data_b64 === Buffer.from('ORIGDATA').toString('base64'), 'attachment_read 原图不受影响', attRead2.body); // 下载端点(带原始文件名) -const dlAtt = await api(A, 'POST', '/api/attachment_download', { id: attAdd.body?.data?.item?.id }, T); -const dlText = await (dlAdd0 => dlAdd0)(fetch(A + '/api/attachment_download', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + T }, body: JSON.stringify({ id: attAdd.body?.data?.item?.id }) }).then(async r => ({ s: r.status, ct: r.headers.get('content-type'), cd: r.headers.get('content-disposition'), t: await r.text() }))); +const dlAtt = await api(A, 'POST', '/api/attachment_download', { id: attAdd.body?.data?.item?.id }, D); +const dlText = await (dlAdd0 => dlAdd0)(fetch(A + '/api/attachment_download', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + D }, body: JSON.stringify({ id: attAdd.body?.data?.item?.id }) }).then(async r => ({ s: r.status, ct: r.headers.get('content-type'), cd: r.headers.get('content-disposition'), t: await r.text() }))); ok(dlText.s === 200 && dlText.t === 'ORIGDATA' && /photo\.jpg/.test(dlText.cd), 'attachment_download 端点(文件名+内容)', { s: dlText.s, cd: dlText.cd, t: dlText.t.slice(0, 40) }); // PUT 原始二进制上传(新通道) const rawUp = await fetch(A + '/sync/attachment/raw?owner=' + fpId + '&name=raw.bin&mime=application/octet-stream', { - method: 'PUT', headers: { authorization: 'Bearer ' + T, 'content-type': 'application/octet-stream' }, + method: 'PUT', headers: { authorization: 'Bearer ' + D, 'content-type': 'application/octet-stream' }, body: Buffer.from('RAWORIGDATA'), }).then(async r => ({ s: r.status, b: await r.json().catch(() => null) })); ok(rawUp.s === 200 && !!rawUp.b?.data?.item?.id, 'PUT raw 二进制上传', rawUp.body); -const tSet = await api(A, 'POST', '/api/attachment_set_thumb', { id: rawUp.b?.data?.item?.id, thumb_b64: Buffer.from('RAWSUMB').toString('base64') }, T); +const tSet = await api(A, 'POST', '/api/attachment_set_thumb', { id: rawUp.b?.data?.item?.id, thumb_b64: Buffer.from('RAWSUMB').toString('base64') }, D); ok(tSet.body?.ok === true, 'attachment_set_thumb 回填缩略图', tSet.body); -const tGet = await api(A, 'POST', '/api/attachment_thumb', { id: rawUp.b?.data?.item?.id }, T); +const tGet = await api(A, 'POST', '/api/attachment_thumb', { id: rawUp.b?.data?.item?.id }, D); ok(tGet.body?.data?.data_b64 === Buffer.from('RAWSUMB').toString('base64'), 'attachment_thumb 读回 raw 缩略图', tGet.body); // ---- admin 用户删除级联 ---- @@ -94,7 +102,7 @@ ok(regDel.status === 200, 'deltgt 注册', regDel.body); const usersBefore = await api(A, 'GET', '/admin/users', undefined, T); const delUser = usersBefore.body?.data?.users?.find((u) => u.username === 'deltgt'); ok(!!delUser, 'deltgt 出现在用户列表', delUser); -const selfUser = usersBefore.body?.data?.users?.find((u) => u.username === 'demo'); +const selfUser = usersBefore.body?.data?.users?.find((u) => u.username === 'root'); const selfDel = await api(A, 'POST', '/admin/users/delete', { user_id: selfUser.id }, T); ok(selfDel.status !== 200, '不能删除自己(高危保护)', selfDel.status); const delResp = await api(A, 'POST', '/admin/users/delete', { user_id: delUser.id }, T); @@ -119,9 +127,9 @@ const pGet2 = await api(A, 'POST', '/api/profile_get', {}, T); ok(pGet2.body?.data?.content?.info?.name === '李四', 'profile 单行更新语义', pGet2.body); // 删除整条记录 → 附件级联清理 -const fpDel = await api(A, 'POST', '/api/footprint_delete', { id: fpId }, T); +const fpDel = await api(A, 'POST', '/api/footprint_delete', { id: fpId }, D); ok(fpDel.body?.ok === true, '删除整条记录'); -const attAfter = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, T); +const attAfter = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, D); ok((attAfter.body?.data?.items || []).length === 0, '记录删除后附件级联清空', attAfter.body); // 邀请码:增 @@ -162,8 +170,11 @@ process.env.PORT = '8892'; const B = await startServer(8892, 2); const login2 = await api(B, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(login2.status === 200, '重启后 demo 可登录'); -const T2 = login2.body.data.token; -const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T2); +const D2 = login2.body.data.token; +const rootLogin2 = await api(B, 'POST', '/auth/login', { username: 'root', password: 'password123' }); +ok(rootLogin2.status === 200, '重启后 root 可登录', rootLogin2.body); +const T2 = rootLogin2.body.data.token; +const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D2); ok(fp2.body?.data?.total === 16, '重启后仍是 16 条(测试足迹已被级联删除)', fp2.body?.data?.total); const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites; ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化'); @@ -176,11 +187,11 @@ const C = await startServer(8893, 3); const alice = await api(C, 'POST', '/auth/register', { username: 'alice', password: 'password123', invite_code: 'Ci-Static-Code' }); ok(alice.status === 200, '首位用户用静态码注册(大小写不敏感)', alice.body); const aliceMe = await api(C, 'GET', '/auth/me', undefined, alice.body.data.token); -ok(aliceMe.body?.data?.is_admin === false, 'alice 在启动播种的 demo 之后注册 → 非管理员', aliceMe.body); +ok(aliceMe.body?.data?.is_admin === true, '首位真实用户(demo 之后注册)自动成为管理员', aliceMe.body); const demoC = await api(C, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(demoC.status === 200, '启动播种的 demo 可登录', demoC.body); const demoMe = await api(C, 'GET', '/auth/me', undefined, demoC.body.data.token); -ok(demoMe.body?.data?.is_admin === true, '启动播种的 demo 是首位用户 → 管理员', demoMe.body); +ok(demoMe.body?.data?.is_admin === false, 'demo 永远不是管理员', demoMe.body); // seed 绕过:库中已有用户时,register(seed:true) 不要求邀请码(历史 bug 回归) const authC = await import(url.pathToFileURL(path.join(root, 'sync-server/src/auth.mjs')).href + '?phase=3b'); const seeded2 = authC.register({ username: 'seeduser2', password: 'password123', device_name: 't', seed: true }); diff --git a/sync-server/src/auth.mjs b/sync-server/src/auth.mjs index 0870e93..6a09c24 100644 --- a/sync-server/src/auth.mjs +++ b/sync-server/src/auth.mjs @@ -11,10 +11,12 @@ export function register({ username, password, device_name, invite_code, seed }) if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员'); } const userCount = d.prepare('SELECT COUNT(*) AS c FROM users').get().c; + const hasAdmin = d.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get().c; let isAdmin = 0; - if (userCount === 0) { - isAdmin = 1; // 首位注册者自动成为管理员 - } else if (seed) { + if (username !== 'demo' && hasAdmin === 0) { + isAdmin = 1; // 首个真实用户自动成为管理员(demo 只是演示账号,不占管理员席位) + } + if (seed) { // 内置演示账号播种:不要求邀请码 } else { const code = String(invite_code || '').trim().toUpperCase(); diff --git a/sync-server/src/db.mjs b/sync-server/src/db.mjs index 1b880d2..c250f9e 100644 --- a/sync-server/src/db.mjs +++ b/sync-server/src/db.mjs @@ -3,15 +3,22 @@ import { DatabaseSync } from 'node:sqlite'; import fs from 'node:fs'; import path from 'node:path'; -export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); -export const BLOB_DIR = path.join(DATA_DIR, 'blobs'); -const DB_PATH = path.join(DATA_DIR, 'sync.db'); - +export function blobDirNow() { return path.join(dataDirNow(), 'blobs'); } +export let BLOB_DIR = path.join(dataDirNow(), 'blobs'); +export function dataDirNow() { + return process.env.DATA_DIR || path.join(process.cwd(), 'data'); +} let db = null; export function getDb() { - if (db) return db; - fs.mkdirSync(BLOB_DIR, { recursive: true }); + const DATA_DIR = dataDirNow(); + const blobDir = path.join(DATA_DIR, 'blobs'); + const DB_PATH = path.join(DATA_DIR, 'sync.db'); + if (db && getDb._dir === DATA_DIR) return db; + if (db) { try { db.close(); } catch {} } + getDb._dir = DATA_DIR; + BLOB_DIR = blobDir; + fs.mkdirSync(blobDir, { recursive: true }); db = new DatabaseSync(DB_PATH); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index 6258417..3553610 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -233,6 +233,7 @@ const server = http.createServer(async (req, res) => { 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 }); @@ -568,6 +569,8 @@ try { } 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); }