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.
- package/README.md +68 -12
- package/dist/assets/index-Bzt3QHIz.js +102 -0
- package/dist/index.html +1 -1
- package/package.json +17 -8
- package/packages/guanwei-api/package.json +26 -0
- package/packages/guanwei-api/src/mcp-http.ts +89 -0
- package/packages/guanwei-api/src/mcp.ts +108 -0
- package/packages/guanwei-api/src/routes/arts.ts +23 -0
- package/packages/guanwei-api/src/routes/chart.ts +24 -0
- package/packages/guanwei-api/src/server.ts +96 -0
- package/server/package.json +24 -0
- package/server/src/index.ts +72 -16
- package/server/src/routes/ai.ts +10 -22
- package/server/src/routes/divine.ts +34 -33
- package/server/src/routes/hour.ts +4 -0
- package/server/src/routes/tarot.ts +30 -7
- package/server/src/routes/users.ts +118 -123
- package/server/src/services/auth.ts +50 -0
- package/server/src/services/dataDir.ts +95 -0
- package/server/src/services/db.ts +131 -0
- package/server/src/services/divineStore.ts +9 -55
- package/server/src/services/duanyu.ts +3 -3
- package/server/src/services/llmProvider.ts +4 -3
- package/server/src/services/usersStore.ts +221 -0
- package/shared/core/data/gua64.ts +5 -5
- package/shared/core/data/qimen.ts +11 -9
- package/shared/core/data/ziwei.ts +16 -15
- package/shared/core/engine/bazi.ts +64 -13
- package/shared/core/engine/calendar.ts +18 -5
- package/shared/core/engine/chart.ts +3 -1
- package/shared/core/engine/liuren.ts +32 -13
- package/shared/core/engine/liuyao.ts +6 -1
- package/shared/core/engine/qimen.ts +102 -60
- package/shared/core/engine/trueSolarTime.ts +9 -6
- package/shared/core/engine/xiaoliuren.ts +6 -2
- package/shared/core/engine/ziwei.ts +4 -3
- package/shared/core/types.ts +7 -7
- package/src/components/arts/XiaoliurenArt.tsx +1 -1
- package/src/pages/AuthPage.tsx +3 -0
- package/src/pages/DemoPage.tsx +1 -1
- package/src/services/api.ts +38 -6
- package/src/utils/userStore.ts +7 -3
- package/vite.config.ts +7 -3
- package/dist/assets/index-BZTUsUN1.js +0 -102
- package/dist/assets/index-BZTUsUN1.js.map +0 -1
- package/scripts/release.sh +0 -81
- package/server/src/data/db.json +0 -7226
- package/server/src/data/guanwei.db +0 -0
package/server/src/routes/ai.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
|
|
2
2
|
import { Router } from 'express';
|
|
3
|
-
import
|
|
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 →
|
|
15
|
+
// 请求 token → 用户名(统一走 services/auth:含 tokenExpires 校验,2026-09 修 P1-1)
|
|
17
16
|
function authedUsername(req: any): string | null {
|
|
18
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
6
|
-
import
|
|
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
|
|
17
|
-
// 返回 null=失败;否则 { existed
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; //
|
|
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
|
|
105
|
-
claimToken: authed ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 (
|
|
104
|
-
|
|
105
|
-
const
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
132
|
-
const user =
|
|
133
|
-
|
|
134
|
-
const ok = await verifyPassword(String(password || ''), user.passHash);
|
|
135
|
-
if (!ok) return res.status(401).json({ error: '名号或密语未合' });
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
181
|
-
const
|
|
182
|
-
if (!
|
|
183
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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;
|