guanwei 1.3.2 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +68 -12
  2. package/dist/assets/index-Bzt3QHIz.js +102 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +17 -8
  5. package/packages/guanwei-api/package.json +26 -0
  6. package/packages/guanwei-api/src/mcp-http.ts +89 -0
  7. package/packages/guanwei-api/src/mcp.ts +108 -0
  8. package/packages/guanwei-api/src/routes/arts.ts +23 -0
  9. package/packages/guanwei-api/src/routes/chart.ts +24 -0
  10. package/packages/guanwei-api/src/server.ts +96 -0
  11. package/server/package.json +24 -0
  12. package/server/src/index.ts +72 -16
  13. package/server/src/routes/ai.ts +10 -22
  14. package/server/src/routes/divine.ts +34 -33
  15. package/server/src/routes/hour.ts +4 -0
  16. package/server/src/routes/tarot.ts +30 -7
  17. package/server/src/routes/users.ts +118 -123
  18. package/server/src/services/auth.ts +50 -0
  19. package/server/src/services/dataDir.ts +95 -0
  20. package/server/src/services/db.ts +131 -0
  21. package/server/src/services/divineStore.ts +9 -55
  22. package/server/src/services/duanyu.ts +3 -3
  23. package/server/src/services/llmProvider.ts +4 -3
  24. package/server/src/services/usersStore.ts +221 -0
  25. package/shared/core/data/gua64.ts +5 -5
  26. package/shared/core/data/qimen.ts +11 -9
  27. package/shared/core/data/ziwei.ts +16 -15
  28. package/shared/core/engine/bazi.ts +64 -13
  29. package/shared/core/engine/calendar.ts +18 -5
  30. package/shared/core/engine/chart.ts +3 -1
  31. package/shared/core/engine/liuren.ts +32 -13
  32. package/shared/core/engine/liuyao.ts +6 -1
  33. package/shared/core/engine/qimen.ts +102 -60
  34. package/shared/core/engine/trueSolarTime.ts +9 -6
  35. package/shared/core/engine/xiaoliuren.ts +6 -2
  36. package/shared/core/engine/ziwei.ts +4 -3
  37. package/shared/core/types.ts +7 -7
  38. package/src/components/arts/XiaoliurenArt.tsx +1 -1
  39. package/src/pages/AuthPage.tsx +3 -0
  40. package/src/pages/DemoPage.tsx +1 -1
  41. package/src/services/api.ts +38 -6
  42. package/src/utils/userStore.ts +7 -3
  43. package/vite.config.ts +7 -3
  44. package/dist/assets/index-BZTUsUN1.js +0 -102
  45. package/dist/assets/index-BZTUsUN1.js.map +0 -1
  46. package/scripts/release.sh +0 -81
  47. package/server/src/data/db.json +0 -7226
  48. package/server/src/data/guanwei.db +0 -0
@@ -1,8 +1,7 @@
1
1
  // AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
2
2
  import { Router } from 'express';
3
- import fs from 'fs';
3
+ import { resolveTokenUser } from '../services/auth.js';
4
4
  import path from 'path';
5
- import crypto from 'crypto';
6
5
  import { fileURLToPath } from 'url';
7
6
  import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messages } from '../services/promptBuilder.js';
8
7
  import { chatOnce, chatStream, activeProvider, providerStatus, lastFinishReason } from '../services/llmProvider.js';
@@ -13,29 +12,18 @@ import { MINGPAN_TEMPLATE } from '../services/promptBuilder.js';
13
12
 
14
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
14
 
16
- // 请求 token → 用户名(与 divine.ts 同策略:有 token 以 token 为准,防 body 自报他人名)
15
+ // 请求 token → 用户名(统一走 services/auth:含 tokenExpires 校验,2026-09 修 P1-1)
17
16
  function authedUsername(req: any): string | null {
18
- const tk = String(req.headers['x-guanwei-token'] || '');
19
- if (!tk) return null;
20
- try {
21
- const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
22
- const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
23
- return user ? user.username : null;
24
- } catch { return null; }
17
+ return resolveTokenUser(req)?.username ?? null;
25
18
  }
26
19
 
27
20
 
28
- // 解读归属校验(与 divine.resolveOwner 同策略):正式账号须本人 token,占位账号兼容无 token
29
- function canReadChart(req: any, recUsername: string, fallbackUsername: string): boolean {
21
+ // 解读归属校验:正式账号须本人 token;占位账号仅限「可信内网来源」无 token 访问
22
+ // (2026-09 修 P0-2:原实现 catch 分支 fail-open,读库失败即放行,配合无鉴权建档可跨用户读档案/耗 AI 额度)
23
+ function canReadChart(req: any, recUsername: string): boolean {
24
+ // 解读 = 读取排盘记录 + 消耗 owner 的 LLM 额度:一律须持凭据(2026-09 W38 收紧)
30
25
  const authed = authedUsername(req);
31
- if (authed) return authed === recUsername;
32
- // 无 token:仅当目标是占位账号(未正式注册)时允许 fallback username
33
- if (fallbackUsername && fallbackUsername !== recUsername) return false;
34
- try {
35
- const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
36
- const user = db.users.find((u: any) => u.username === recUsername);
37
- return !user || !user.passHash; // 占位账号放行
38
- } catch { return true; }
26
+ return !!authed && authed === recUsername;
39
27
  }
40
28
 
41
29
  // 非流式:token 优先归属
@@ -57,7 +45,7 @@ router.post('/interpret', async (req, res) => {
57
45
  if (divineId) {
58
46
  const rec = getDivination(divineId);
59
47
  if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
60
- if (!canReadChart(req, rec.username, String(username || ''))) return res.status(403).json({ error: 'FORBIDDEN' });
48
+ if (!canReadChart(req, rec.username)) return res.status(403).json({ error: 'FORBIDDEN' });
61
49
  resultRaw = rec.resultRaw;
62
50
  recProfile = rec.profile;
63
51
  } else {
@@ -138,7 +126,7 @@ router.post('/interpret/stream', async (req, res) => {
138
126
  res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
139
127
  return;
140
128
  }
141
- if (!canReadChart(req, rec.username, String(username || ''))) {
129
+ if (!canReadChart(req, rec.username)) {
142
130
  res.status(403).json({ error: 'FORBIDDEN' });
143
131
  return;
144
132
  }
@@ -2,28 +2,24 @@
2
2
  import { Router } from 'express';
3
3
  import { chartCalc } from '../../../shared/core/engine/chart.js';
4
4
  import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
5
- import fs from 'fs';
6
- import path from 'path';
7
- import crypto from 'crypto';
8
- import { fileURLToPath } from 'url';
9
-
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const DB_FILE = path.join(__dirname, '..', 'data', 'db.json');
5
+ import { resolveTokenUser, allowAnonymous } from '../services/auth.js';
6
+ import { getUser, createUserIfAbsent, newToken } from '../services/usersStore.js';
12
7
 
13
8
  const router = Router();
14
9
  const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
15
10
 
16
- // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;建档即发 claimToken 防抢占)
17
- // 返回 null=失败;否则 { existed: 是否已存在, claimToken: 该账号当前 token(新建时返回,供前端保存用于后续注册升级)}
11
+ // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts register 同策略;新建时发 claimToken 防抢占)
12
+ // 返回 null=失败;否则 { existed, claimToken }
13
+ // 安全(2026-09 修 P0-1):claimToken 只在「本次新建」时返回,绝不回吐已存在账号的 token——
14
+ // 否则任何人只要知道 username 即可取走占位账号凭据并 register 抢占(含档案/起占历史)。
15
+ // 存储(W38 #4):INSERT OR IGNORE 原子建档,并发请求只有一个拿到 claimToken。
18
16
  function ensureUser(username: string): { existed: boolean; claimToken: string } | null {
19
17
  if (!username || typeof username !== 'string') return null;
20
18
  try {
21
- const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
22
- const exist = db.users.find((u: any) => u.username === username);
23
- if (exist) return { existed: true, claimToken: exist.token || '' };
24
- const token = crypto.randomBytes(32).toString('hex');
25
- db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
26
- fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
19
+ if (getUser(username)) return { existed: true, claimToken: '' }; // 已存在账号绝不回吐 token(P0-1)
20
+ const token = newToken();
21
+ const created = createUserIfAbsent({ username, token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
22
+ if (!created) return { existed: true, claimToken: '' };
27
23
  console.log('[divine] 自动建档:', username);
28
24
  return { existed: false, claimToken: token };
29
25
  } catch (e: any) {
@@ -34,13 +30,7 @@ function ensureUser(username: string): { existed: boolean; claimToken: string }
34
30
 
35
31
  // 请求携带的 token → 对应用户名(有 token 则以其为准,堵「query/body 自报 username」越权)
36
32
  function authedUsername(req: any): string | null {
37
- const tk = String(req.headers['x-guanwei-token'] || '');
38
- if (!tk) return null;
39
- try {
40
- const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
41
- const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
42
- return user ? user.username : null;
43
- } catch { return null; }
33
+ return resolveTokenUser(req)?.username ?? null;
44
34
  }
45
35
 
46
36
  // 归属校验统一入口:
@@ -49,14 +39,24 @@ function authedUsername(req: any): string | null {
49
39
  // · 目标为占位账号(passHash 空,自动建档/未正式注册)→ 允许(本地单机流程)
50
40
  // · 目标为正式账号(注册过,passHash 非空)→ 401(必须持本人 token,H-NEW1~3)
51
41
  // 返回 { owner, isPlaceholder };null = 校验失败(已 res 响应)
52
- function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: string; isPlaceholder: boolean } | null {
42
+ function resolveOwner(req: any, res: any, fallbackUsername: string, opts: { requireToken?: boolean } = {}): { owner: string; isPlaceholder: boolean } | null {
53
43
  const authed = authedUsername(req);
44
+ // 读/删类操作(历史、详情、删除)一律须持凭据——占位账号亦然(2026-09 W38 收紧 #3 残余:
45
+ // 原实现允许同内网凭 username 匿名读他人起占历史)。本地流程由前端携带起占时下发的 claimToken。
46
+ if (!authed && opts.requireToken) {
47
+ res.status(401).json({ error: 'AUTH_REQUIRED', message: '读取档案/记录需携带本人凭据(本地流程请携带起占时下发的 claimToken)' });
48
+ return null;
49
+ }
50
+ // 匿名来源限制(P0-3):公网来源必须持 token;仅回环/内网允许无 token 的本地流程
51
+ if (!authed && !allowAnonymous(req)) {
52
+ res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后操作(公网访问不开放匿名起占)' });
53
+ return null;
54
+ }
54
55
  let owner = authed || String(fallbackUsername || '');
55
56
  if (!owner) { res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)' }); return null; }
56
57
  let user: any = null;
57
58
  try {
58
- const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
59
- user = db.users.find((u: any) => u.username === owner) || null;
59
+ user = getUser(owner);
60
60
  } catch { /* 读失败按占位处理 */ }
61
61
  const isPlaceholder = !user || !user.passHash;
62
62
  if (!authed && !isPlaceholder) {
@@ -68,7 +68,7 @@ function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: st
68
68
  }
69
69
 
70
70
  // POST /api/divine —— 起占入库
71
- router.post('/', (req, res) => {
71
+ router.post('/', async (req, res) => {
72
72
  const { artId, inputs, profile, question, username, profileId } = req.body || {};
73
73
  const resolved = resolveOwner(req, res, String(username || ''));
74
74
  if (!resolved) return;
@@ -77,7 +77,7 @@ router.post('/', (req, res) => {
77
77
  if (!ensured) {
78
78
  return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
79
79
  }
80
- const claimToken = ensured.claimToken; // 新建占位账号时返回,供前端保存(L-NEW1)
80
+ const claimToken = ensured.claimToken; // 仅「本次新建」时非空(P0-1)
81
81
  if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
82
82
 
83
83
  let resultRaw: unknown;
@@ -101,17 +101,17 @@ router.post('/', (req, res) => {
101
101
  const authed = authedUsername(req);
102
102
  res.json({
103
103
  ok: true, divineId: rec.id, resultRaw, display: rec.display,
104
- // L-NEW1:请求未带 token(本地占位流程)→ 返回 claimToken,前端保存后注册可升级;带 token 则无需
105
- claimToken: authed ? undefined : ensured.claimToken,
104
+ // L-NEW1:仅未鉴权 + 本次新建占位账号时返回 claimToken(前端保存后注册可升级);已存在账号一律不回吐
105
+ claimToken: (!authed && !ensured.existed && claimToken) ? claimToken : undefined,
106
106
  });
107
107
  });
108
108
 
109
109
  // GET /api/divine?username=&page=&pageSize= —— 占卜历史(H-NEW2:正式账号须本人 token)
110
- router.get('/', (req, res) => {
110
+ router.get('/', async (req, res) => {
111
111
  const username = String(req.query.username || '');
112
112
  const page = Number(req.query.page || 1);
113
113
  const pageSize = Number(req.query.pageSize || 20);
114
- const resolved = resolveOwner(req, res, username);
114
+ const resolved = resolveOwner(req, res, username, { requireToken: true });
115
115
  if (!resolved) return;
116
116
  if (!ensureUser(resolved.owner)) return res.status(401).json({ error: 'UNAUTHORIZED' });
117
117
  res.json(listDivinations(resolved.owner, page, pageSize, String(req.query.profileId || '')));
@@ -122,9 +122,10 @@ router.get('/:id', (req, res) => {
122
122
  const rec = getDivination(req.params.id);
123
123
  if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
124
124
  // 归属校验:token 优先;无 token 仅占位账号可经 query username 访问
125
- const resolved = resolveOwner(req, res, String(req.query.username || ''));
125
+ const resolved = resolveOwner(req, res, String(req.query.username || ''), { requireToken: true });
126
126
  if (!resolved) return;
127
- if (resolved.owner !== rec.username) return res.status(403).json({ error: 'FORBIDDEN' });
127
+ // 归属不符统一返回 404(原 403/404 差异可枚举 divineId 存在性,2026-09 修 P1-7)
128
+ if (resolved.owner !== rec.username) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
128
129
  res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
129
130
  });
130
131
 
@@ -12,6 +12,10 @@ router.post('/hour-infer', (req, res) => {
12
12
  if (!Array.isArray(events) || events.length === 0) {
13
13
  return res.status(400).json({ error: '请至少提供一条关键人生事件' });
14
14
  }
15
+ // 条数上限(2026-09 修 P2-7):每条事件会触发 12 候选 × baziCalc,原无上限可被放大
16
+ if (events.length > 50) {
17
+ return res.status(400).json({ error: 'TOO_MANY_EVENTS', message: '关键事件请控制在 50 条以内' });
18
+ }
15
19
  const cleanEvents: HourInferEvent[] = events
16
20
  .map((e: any) => ({
17
21
  year: Number(e?.year),
@@ -21,7 +21,12 @@ router.post('/draw', (req, res) => {
21
21
  let spread: Spread | undefined;
22
22
 
23
23
  if (customSpread) {
24
- spread = customSpread;
24
+ // 自定义牌阵限长(2026-09 修 P0-5):原实现按 positions.length 无上限循环,可被 10 万项拖死
25
+ const pos = Array.isArray(customSpread?.positions) ? customSpread.positions : [];
26
+ if (pos.length === 0 || pos.length > 20) {
27
+ return res.status(400).json({ error: 'BAD_SPREAD', message: '自定义牌阵位数为 1-20' });
28
+ }
29
+ spread = { ...customSpread, positions: pos.slice(0, 20) } as Spread;
25
30
  } else {
26
31
  spread = defaultSpreads.find(s => s.id === spreadId);
27
32
  }
@@ -41,10 +46,17 @@ router.post('/interpret', (req, res) => {
41
46
  return res.status(400).json({ error: '缺少必要参数' });
42
47
  }
43
48
 
49
+ // 抽牌/解读入参限长(同上)
50
+ if (!Array.isArray(cards) || cards.length === 0 || cards.length > 20) {
51
+ return res.status(400).json({ error: 'BAD_CARDS', message: '牌数需为 1-20' });
52
+ }
53
+ if (String(question).length > 300) {
54
+ return res.status(400).json({ error: 'BAD_QUESTION', message: '所问之事请控制在 300 字以内' });
55
+ }
44
56
  const result = generateInterpretation(
45
- cards,
46
- spread,
47
- question,
57
+ cards.slice(0, 20),
58
+ { ...spread, positions: (spread.positions || []).slice(0, 20) },
59
+ String(question).slice(0, 300),
48
60
  category as QuestionCategory
49
61
  );
50
62
 
@@ -69,13 +81,22 @@ router.post('/interpret/stream', (req, res) => {
69
81
  return res.status(400).json({ error: '缺少必要参数' });
70
82
  }
71
83
 
84
+ // 抽牌/解读入参限长(同上)
85
+ if (!Array.isArray(cards) || cards.length === 0 || cards.length > 20) {
86
+ return res.status(400).json({ error: 'BAD_CARDS', message: '牌数需为 1-20' });
87
+ }
88
+ if (String(question).length > 300) {
89
+ return res.status(400).json({ error: 'BAD_QUESTION', message: '所问之事请控制在 300 字以内' });
90
+ }
72
91
  const result = generateInterpretation(
73
- cards,
74
- spread,
75
- question,
92
+ cards.slice(0, 20),
93
+ { ...spread, positions: (spread.positions || []).slice(0, 20) },
94
+ String(question).slice(0, 300),
76
95
  category as QuestionCategory
77
96
  );
78
97
 
98
+ let closed = false;
99
+ req.on('close', () => { closed = true; }); // 客户端断开 → 停止逐字推送(修 P3-2)
79
100
  res.setHeader('Content-Type', 'text/event-stream');
80
101
  res.setHeader('Cache-Control', 'no-cache');
81
102
  res.setHeader('Connection', 'keep-alive');
@@ -83,6 +104,7 @@ router.post('/interpret/stream', (req, res) => {
83
104
  let sectionIndex = 0;
84
105
 
85
106
  const sendSection = () => {
107
+ if (closed) return; // 客户端已断开:停止计时器链
86
108
  if (sectionIndex >= result.sections.length) {
87
109
  res.write('event: done\n');
88
110
  res.write('data: [DONE]\n\n');
@@ -95,6 +117,7 @@ router.post('/interpret/stream', (req, res) => {
95
117
  let charIndex = 0;
96
118
 
97
119
  const typeChar = () => {
120
+ if (closed) return; // 同上
98
121
  if (charIndex >= chars.length) {
99
122
  sectionIndex++;
100
123
  setTimeout(sendSection, 300);
@@ -1,12 +1,11 @@
1
- // 用户/档案/记录 API(本地 JSON 文件存储,供前后端联调)
1
+ // 用户/档案/记录 API(存储:统一 SQLite users 表,见 services/usersStore)
2
2
  import { Router } from 'express';
3
- import fs from 'fs';
4
- import path from 'path';
5
3
  import crypto from 'crypto';
6
- import { fileURLToPath } from 'url';
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- const DB_FILE = path.join(__dirname, '..', 'data', 'db.json');
4
+ import {
5
+ getUser, createUserIfAbsent, upgradePlaceholder, rotateToken, setPassHash,
6
+ mergeProfile, addSample, removeSample, setRecords,
7
+ newToken, tokenMatches, type UserRow,
8
+ } from '../services/usersStore.js';
10
9
 
11
10
  function scryptAsync(pw: string, salt: Buffer, keylen: number): Promise<Buffer> {
12
11
  return new Promise((resolve, reject) => {
@@ -14,29 +13,49 @@ function scryptAsync(pw: string, salt: Buffer, keylen: number): Promise<Buffer>
14
13
  });
15
14
  }
16
15
 
17
- interface DbUser {
18
- username: string;
19
- passHash: string;
20
- createdAt: number;
21
- profile: Record<string, unknown>;
22
- samples: { id: string; name: string; profile: Record<string, unknown> }[];
23
- records: Record<string, unknown>[];
24
- /** 登录态 token(注册/登录时签发,云同步写接口须携带校验归属) */
25
- token?: string;
26
- /** token 过期时间(ms;30 天滚动,登录时轮换续期) */
27
- tokenExpires?: number;
28
- }
29
-
30
- interface Db { users: DbUser[] }
16
+ // ─── 输入校验(2026-09 安全修复)───
17
+ // profile/records 原样入库会被后续注入 AI prompt 并落盘;此处白名单化 + 拒绝原型污染键
18
+ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
31
19
 
32
- function loadDb(): Db {
33
- try { return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')); }
34
- catch { return { users: [] }; }
20
+ function isSafeKey(k: string): boolean { return !DANGEROUS_KEYS.includes(k); }
21
+ function cleanString(v: unknown, max = 200): string {
22
+ return typeof v === 'string' ? v.slice(0, max) : '';
35
23
  }
36
-
37
- function saveDb(db: Db): void {
38
- fs.mkdirSync(path.dirname(DB_FILE), { recursive: true });
39
- fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
24
+ /** 出生档案白名单校验:仅保留已知字段,限制长度/数值范围 */
25
+ function sanitizeProfile(input: any): Record<string, unknown> | null {
26
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
27
+ const p = input as Record<string, any>;
28
+ const out: Record<string, unknown> = {};
29
+ if (typeof p.birthDate === 'string' && /^\d{4}-\d{1,2}-\d{1,2}$/.test(p.birthDate)) out.birthDate = p.birthDate.slice(0, 10);
30
+ if (typeof p.birthTime === 'string' && /^\d{1,2}:\d{2}$/.test(p.birthTime)) out.birthTime = p.birthTime.slice(0, 5);
31
+ if (Number.isInteger(p.birthHourIndex) && p.birthHourIndex >= -1 && p.birthHourIndex <= 11) out.birthHourIndex = p.birthHourIndex;
32
+ if (typeof p.birthTimeUnknown === 'boolean') out.birthTimeUnknown = p.birthTimeUnknown;
33
+ if (p.gender === '男' || p.gender === '女') out.gender = p.gender;
34
+ if (p.location && typeof p.location === 'object' && !Array.isArray(p.location)) {
35
+ const l = p.location as Record<string, any>;
36
+ const lng = Number(l.lng), lat = Number(l.lat);
37
+ if (Number.isFinite(lng) && lng >= -180 && lng <= 180 && Number.isFinite(lat) && lat >= -90 && lat <= 90) {
38
+ out.location = { province: cleanString(l.province, 40), city: cleanString(l.city, 40), district: cleanString(l.district, 40), lng, lat };
39
+ }
40
+ }
41
+ if (Array.isArray(p.lifeEvents)) {
42
+ out.lifeEvents = p.lifeEvents.slice(0, 50)
43
+ .filter((e: any) => e && typeof e === 'object' && Number.isInteger(e.year) && e.year >= 1900 && e.year <= 2200)
44
+ .map((e: any) => ({ year: e.year, text: cleanString(e.text, 200) }));
45
+ }
46
+ for (const k of Object.keys(out)) if (!isSafeKey(k)) delete out[k];
47
+ if (JSON.stringify(out).length > 20000) return null;
48
+ return out;
49
+ }
50
+ /** 记录同步校验:整表覆盖须限条数与单条体积 */
51
+ function sanitizeRecords(input: any): Record<string, unknown>[] | null {
52
+ if (!Array.isArray(input)) return null;
53
+ if (input.length > 2000) return null;
54
+ for (const item of input) {
55
+ if (item && typeof item === 'object' && JSON.stringify(item).length > 20000) return null;
56
+ if (item && typeof item === 'object' && Object.keys(item).some(k => !isSafeKey(k))) return null;
57
+ }
58
+ return input;
40
59
  }
41
60
 
42
61
  // 密码哈希:scrypt(带随机盐,防彩虹表/暴力破解)。
@@ -51,18 +70,6 @@ async function hashPassword(pw: string): Promise<string> {
51
70
 
52
71
  const TOKEN_TTL = 30 * 24 * 3600 * 1000; // 30 天
53
72
 
54
- function newToken(): string {
55
- return crypto.randomBytes(32).toString('hex');
56
- }
57
-
58
- function tokenMatches(user: DbUser, token: string | undefined): boolean {
59
- if (!token || !user.token) return false;
60
- if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
61
- // 恒时比较,防时序侧信道
62
- const a = Buffer.from(token), b = Buffer.from(user.token);
63
- return a.length === b.length && crypto.timingSafeEqual(a, b);
64
- }
65
-
66
73
  async function verifyPassword(pw: string, stored: string): Promise<boolean> {
67
74
  if (!stored) return false;
68
75
  if (stored.startsWith('scrypt$')) {
@@ -80,136 +87,124 @@ async function verifyPassword(pw: string, stored: string): Promise<boolean> {
80
87
  return 'h' + h.toString(16) === stored;
81
88
  }
82
89
 
83
- const router = Router();
84
-
85
- // 注册
86
- // 云同步 upsert:本地账号(无密语)不存在时自动建档,避免因数据清理导致 404
87
- function upsertUser(username: string): DbUser {
88
- const db = loadDb();
89
- let user = db.users.find((x: any) => x.username === username);
90
- if (!user) {
91
- user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
92
- db.users.push(user);
93
- saveDb(db);
94
- console.log(`[users] 自动建档: ${username}`);
95
- }
96
- return user;
90
+ /** 登录响应体里的用户视图(不含 token/密语) */
91
+ function publicUser(user: UserRow) {
92
+ return { username: user.username, profile: user.profile, samples: user.samples };
97
93
  }
98
94
 
95
+ const router = Router();
96
+
97
+ // 注册(正式注册 / 占位账号凭 claimToken 升级)
99
98
  router.post('/register', async (req, res) => {
100
99
  const { username, password, profile } = req.body;
101
100
  const name = String(username || '').trim();
102
101
  if (name.length < 2) return res.status(400).json({ error: '名号至少二字' });
103
- if (!password || String(password).length < 4) return res.status(400).json({ error: '密语至少四位' });
104
- const db = loadDb();
105
- const existing = db.users.find(u => u.username === name);
102
+ if (name.length > 24) return res.status(400).json({ error: '名号至多二十四字' });
103
+ if (!password || String(password).length < 8) return res.status(400).json({ error: '密语至少八位' });
104
+ const cleanProfile = sanitizeProfile(profile) || undefined;
105
+
106
+ const existing = getUser(name);
106
107
  if (existing) {
108
+ if (existing.passHash) return res.status(400).json({ error: '此名号已有人用' });
107
109
  // 占位账号(自动建档、无密语):升级须持有建档时发放的 claimToken,防任意抢占
108
- if (!existing.passHash) {
109
- const claimToken = String(req.body.claimToken || '');
110
- if (!tokenMatches(existing, claimToken)) {
111
- return res.status(409).json({ error: 'ACCOUNT_CLAIMED', message: '此名号已被自动建档占用,需持有建档凭据方可注册(或更换名号)' });
112
- }
113
- existing.passHash = await hashPassword(String(password));
114
- existing.token = newToken();
115
- existing.tokenExpires = Date.now() + TOKEN_TTL;
116
- if (profile) existing.profile = { ...existing.profile, ...profile };
117
- saveDb(db);
118
- return res.json({ ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
110
+ if (!tokenMatches(existing, String(req.body.claimToken || ''))) {
111
+ return res.status(409).json({ error: 'ACCOUNT_CLAIMED', message: '此名号已被自动建档占用,需持有建档凭据方可注册(或更换名号)' });
112
+ }
113
+ const passHash = await hashPassword(String(password)); // 异步哈希在事务外完成
114
+ const token = newToken();
115
+ const expires = Date.now() + TOKEN_TTL;
116
+ const profileMerged = { ...existing.profile, ...(cleanProfile || {}) };
117
+ try {
118
+ upgradePlaceholder(name, passHash, token, expires, cleanProfile);
119
+ } catch (e: any) {
120
+ console.error('[users] 占位账号升级失败:', e?.message || e);
121
+ return res.status(500).json({ error: 'UPGRADE_FAILED' });
119
122
  }
120
- return res.status(400).json({ error: '此名号已有人用' });
123
+ console.log(`[users] 占位账号已升级: ${name}`);
124
+ return res.json({ ok: true, upgraded: true, token, user: { username: name, profile: profileMerged, samples: existing.samples } });
121
125
  }
122
- const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
123
- db.users.push(user);
124
- saveDb(db);
125
- res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
126
+
127
+ const passHash = await hashPassword(String(password));
128
+ const token = newToken();
129
+ const expires = Date.now() + TOKEN_TTL;
130
+ const created = createUserIfAbsent({ username: name, passHash, profile: cleanProfile || {}, token, tokenExpires: expires });
131
+ if (!created) return res.status(400).json({ error: '此名号已有人用' }); // 并发下被抢先
132
+ res.json({ ok: true, token, user: { username: name, profile: cleanProfile || {}, samples: [] } });
126
133
  });
127
134
 
128
135
  // 登录
129
136
  router.post('/login', async (req, res) => {
130
137
  const { username, password } = req.body;
131
- const db = loadDb();
132
- const user = db.users.find(u => u.username === String(username || '').trim());
133
- if (!user) return res.status(401).json({ error: '名号或密语未合' });
134
- const ok = await verifyPassword(String(password || ''), user.passHash);
135
- if (!ok) return res.status(401).json({ error: '名号或密语未合' });
136
- // 旧哈希命中 → 升级为 scrypt 存储
137
- if (user.passHash && !user.passHash.startsWith('scrypt$')) {
138
- user.passHash = await hashPassword(String(password));
139
- saveDb(db);
140
- console.log(`[users] 密码哈希已升级为 scrypt: ${user.username}`);
141
- }
142
- // 登录成功 → 轮换 token(旧 token 即失效;防泄露长期有效)
143
- user.token = newToken();
144
- user.tokenExpires = Date.now() + TOKEN_TTL;
145
- saveDb(db);
146
- res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
138
+ const name = String(username || '').trim();
139
+ const user = getUser(name);
140
+ // 名号不存在也走一次等价 scrypt,避免时序差异泄露账号是否存在(P2-2)
141
+ const ok = await verifyPassword(String(password || ''), user ? user.passHash : 'scrypt$' + '0'.repeat(32) + '$' + '0'.repeat(128));
142
+ if (!user || !ok) return res.status(401).json({ error: '名号或密语未合' });
143
+ // 旧格式哈希命中后升级(哈希在事务外算,避免事务内 await)
144
+ const upgraded = user.passHash && !user.passHash.startsWith('scrypt$') ? await hashPassword(String(password)) : null;
145
+ if (upgraded) console.log(`[users] 密码哈希已升级为 scrypt: ${name}`);
146
+ // 登录成功 → 轮换 token(旧 token 即失效)
147
+ const token = newToken();
148
+ const expires = Date.now() + TOKEN_TTL;
149
+ const fresh = getUser(name);
150
+ if (upgraded) setPassHash(name, upgraded);
151
+ rotateToken(name, token, expires);
152
+ res.json({ ok: true, token, user: publicUser(fresh || user) });
147
153
  });
148
154
 
149
155
  // ─── 写接口鉴权中间件:云同步写操作须携带本人 token(堵「知道 username 即可写任意档案」)───
150
156
  function requireOwner(req: any, res: any, next: any): void {
151
157
  const username = req.params.username;
152
158
  const token = String(req.headers['x-guanwei-token'] || '');
153
- const db = loadDb();
154
- const user = db.users.find(u => u.username === username);
159
+ const user = getUser(username);
155
160
  if (!user) return res.status(404).json({ error: '馆中无此人' });
156
161
  if (!tokenMatches(user, token)) return res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后再同步档案' });
157
- (req as any)._dbUser = user;
162
+ req._dbUser = user;
158
163
  next();
159
164
  }
160
165
 
161
166
  // 档案读取/更新(读他人档案也须本人 token——防止知道 username 即可窥探)
162
167
  router.get('/:username/profile', requireOwner, (req, res) => {
163
- const db = loadDb();
164
- const user = db.users.find(u => u.username === req.params.username);
168
+ const user = getUser(req.params.username);
165
169
  if (!user) return res.status(404).json({ error: '馆中无此人' });
166
170
  res.json({ profile: user.profile, samples: user.samples });
167
171
  });
168
172
 
169
173
  router.put('/:username/profile', requireOwner, (req, res) => {
170
- const db = loadDb();
171
- const user = db.users.find(u => u.username === req.params.username);
172
- if (!user) return res.status(404).json({ error: '馆中无此人' });
173
- user.profile = req.body.profile || user.profile;
174
- saveDb(db);
175
- res.json({ ok: true, profile: user.profile });
174
+ const clean = sanitizeProfile(req.body.profile);
175
+ if (!clean) return res.status(400).json({ error: 'BAD_PROFILE', message: '档案格式不合法(字段白名单/长度/数值范围校验未通过)' });
176
+ const profile = mergeProfile(req.params.username, clean);
177
+ if (!profile) return res.status(404).json({ error: '馆中无此人' });
178
+ res.json({ ok: true, profile });
176
179
  });
177
180
 
178
- // 示例档案增删/提升
181
+ // 示例档案增删
179
182
  router.post('/:username/samples', requireOwner, (req, res) => {
180
- const db = loadDb();
181
- const user = db.users.find(u => u.username === req.params.username);
182
- if (!user) return res.status(404).json({ error: '馆中无此人' });
183
- const sample = { id: 's' + Date.now(), name: String(req.body.name || '未名档案'), profile: req.body.profile || {} };
184
- user.samples.push(sample);
185
- saveDb(db);
186
- res.json({ ok: true, samples: user.samples });
183
+ const sample = { id: 's' + Date.now(), name: String(req.body.name || '未名档案').slice(0, 40), profile: sanitizeProfile(req.body.profile) || {} };
184
+ const samples = addSample(req.params.username, sample);
185
+ if (!samples) return res.status(404).json({ error: '馆中无此人' });
186
+ res.json({ ok: true, samples });
187
187
  });
188
188
 
189
189
  router.delete('/:username/samples/:id', requireOwner, (req, res) => {
190
- const db = loadDb();
191
- const user = db.users.find(u => u.username === req.params.username);
192
- if (!user) return res.status(404).json({ error: '馆中无此人' });
193
- user.samples = user.samples.filter(s => s.id !== req.params.id);
194
- saveDb(db);
195
- res.json({ ok: true, samples: user.samples });
190
+ const samples = removeSample(req.params.username, req.params.id);
191
+ if (!samples) return res.status(404).json({ error: '馆中无此人' });
192
+ res.json({ ok: true, samples });
196
193
  });
197
194
 
198
195
  // 记录同步(按用户整表覆盖)
199
196
  router.put('/:username/records', requireOwner, (req, res) => {
200
- const db = loadDb();
201
- const user = db.users.find(u => u.username === req.params.username);
202
- if (!user) return res.status(404).json({ error: '馆中无此人' });
203
- user.records = Array.isArray(req.body.records) ? req.body.records : [];
204
- saveDb(db);
205
- res.json({ ok: true, count: user.records.length });
197
+ const recs = sanitizeRecords(req.body.records);
198
+ if (!recs) return res.status(400).json({ error: 'BAD_RECORDS', message: '记录格式不合法(条数上限 2000 / 单条 20KB / 禁止危险键)' });
199
+ const count = setRecords(req.params.username, recs);
200
+ if (count === null) return res.status(404).json({ error: '馆中无此人' });
201
+ res.json({ ok: true, count });
206
202
  });
207
203
 
208
204
  router.get('/:username/records', requireOwner, (req, res) => {
209
- const db = loadDb();
210
- const user = db.users.find(u => u.username === req.params.username);
205
+ const user = getUser(req.params.username);
211
206
  if (!user) return res.status(404).json({ error: '馆中无此人' });
212
207
  res.json({ records: user.records });
213
208
  });
214
209
 
215
- export default router;
210
+ export default router;