@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0
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 +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由族:鉴权与管理(M3 读路径 + M6-6 RAG 配置三条 + M6.9 账号/管理写八条)
|
|
3
|
+
*
|
|
4
|
+
* 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头;行号是 `server.py` 的):
|
|
5
|
+
* - `POST /api/auth/login` → `_api_login`(1490) **public**
|
|
6
|
+
* - `POST /api/auth/register` → `_api_register`(1541) **public**
|
|
7
|
+
* - `POST /api/auth/logout` → `_api_logout`(1520) (**不读体**)
|
|
8
|
+
* - `POST /api/auth/change_password` → `_api_change_password`(1576)(handler 内**自己读体**)
|
|
9
|
+
* - `GET /api/auth/me` → `_api_me`(M1 在 server.js 里实现,M3 迁入本表)
|
|
10
|
+
* - `GET /api/admin/users` → `_api_admin_list_users`(仅 admin)
|
|
11
|
+
* - `POST /api/admin/users` → `_api_admin_create_user`(1627)
|
|
12
|
+
* - `POST /api/admin/users/<n>` → `_api_admin_update_user`(1650) ┐ 同一 handler:
|
|
13
|
+
* - `PATCH /api/admin/users/<n>` → 同上(`do_PATCH`:1252) ┘ POST/PATCH 两条分派点
|
|
14
|
+
* - `DELETE /api/admin/users/<n>` → `_api_admin_delete_user`(1685)
|
|
15
|
+
* - `POST /api/admin/rag/test` → `_api_admin_rag_test`(1079 分派 → 1896)
|
|
16
|
+
* - `GET /api/admin/rag` → `_api_admin_rag_get`(1324 分派 → 1855)
|
|
17
|
+
* - `PATCH /api/admin/rag` → `_api_admin_rag_set`(1256 分派 → 1874)
|
|
18
|
+
*
|
|
19
|
+
* **`public: true` 的含义**(只标 login/register):Python `do_POST`(1027) 头两行就在
|
|
20
|
+
* `if path.startswith("/api/"): user = self._current_user()` **之前**分派它们——未登录也必须
|
|
21
|
+
* 能打(这正是「登录」的前提),且**不过强制改密闸**(闸在鉴权之后才调)。`server.js` 的公开
|
|
22
|
+
* 端点分支按本标记现算名单(单一真相源),见 `./index.js:isPublicRoute`。
|
|
23
|
+
*
|
|
24
|
+
* **没有** `/api/rag*` 顶层族(2026-10-13 侦察实测):RAG 只有这三条 admin 前缀端点;
|
|
25
|
+
* `rag.py` 的 rebuild/search 是 CLI 子命令,不对应任何 HTTP。
|
|
26
|
+
*
|
|
27
|
+
* 注意 `_api_me` 不吃 `_require_admin`:任何已登录用户都能读自己的三字段。
|
|
28
|
+
*
|
|
29
|
+
* 字节形态(照 oracle,**逐处对照、不许统一**):
|
|
30
|
+
* - 硬编码紧凑字节串 `b'{"error":...}'` ⇒ `ctx.error`(login/register/change_password/
|
|
31
|
+
* admin CRUD 的全部错误体、`user not found` 的 404);成功体 `b'{"ok":true}'` ⇒ `ctx.text`;
|
|
32
|
+
* - `json.dumps(...)` 带空格 ⇒ `ctx.errorSpaced`(仅 RAG 三条的既有形态)或 `ctx.json`
|
|
33
|
+
* (login/register 的正文、`{"id": user_id}`)。
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
LoginLimiter, md5Password, newToken, sessionExpiry, verifyPassword,
|
|
37
|
+
} from '../lib/auth.js';
|
|
38
|
+
import { clearSessionCookie, cookieToken, sessionCookie } from '../lib/cookie.js';
|
|
39
|
+
import * as dbmod from '../lib/db.js';
|
|
40
|
+
import { jsonBody } from '../lib/http.js';
|
|
41
|
+
import { pyStrip } from '../lib/lib.js';
|
|
42
|
+
import * as rag from '../lib/rag.js';
|
|
43
|
+
|
|
44
|
+
/** `_is_admin`:管理员判据**只有**用户名等于 `admin`(需求约定,勿改) */
|
|
45
|
+
function isAdmin(user) {
|
|
46
|
+
return Boolean(user) && user.username === 'admin';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `_require_admin`:非管理员统一 403 `{"error":"仅管理员可操作"}` */
|
|
50
|
+
function requireAdmin(ctx) {
|
|
51
|
+
if (isAdmin(ctx.user)) return true;
|
|
52
|
+
ctx.error(403, '仅管理员可操作');
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `_user_admin_json`:管理页用户行(派生 `is_admin`,口令相关字段一律不下发) */
|
|
57
|
+
function userAdminJson(row) {
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
username: row.username,
|
|
61
|
+
created_at: row.created_at,
|
|
62
|
+
is_admin: row.username === 'admin',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Python `str(x or "")`(标量面;api_base/model 这类表单字段都是字符串) */
|
|
67
|
+
function pyStrOrEmpty(value) {
|
|
68
|
+
if (value === null || value === undefined || value === false || value === 0) return '';
|
|
69
|
+
if (typeof value === 'string') return value;
|
|
70
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
71
|
+
if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '';
|
|
72
|
+
if (typeof value === 'object') return Object.keys(value).length ? JSON.stringify(value) : '';
|
|
73
|
+
return String(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 真值判定(`body.get(x)` 直接进 Python `if`;`{}`/`[]` 为假) */
|
|
77
|
+
function pyTruthy(value) {
|
|
78
|
+
if (value === null || value === undefined) return false;
|
|
79
|
+
if (typeof value === 'boolean') return value;
|
|
80
|
+
if (typeof value === 'number') return value !== 0;
|
|
81
|
+
if (typeof value === 'string') return value.length > 0;
|
|
82
|
+
return Object.keys(value).length > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --------------------------------------------------------------------------
|
|
86
|
+
// 账号族写端点(M6.9)的入参规范化与共用件
|
|
87
|
+
// --------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `server.py:_pw_str`(568):密码入参规范化——**只有字符串照收**,其余(JSON null、数字、
|
|
91
|
+
* 数组、对象)一律当空串。oracle 的理由:`data.get("password")` 显式传 null 时默认值不生效,
|
|
92
|
+
* None 会一路传进 `.encode/.len` 崩掉(无响应断连),故统一收口。
|
|
93
|
+
*
|
|
94
|
+
* 导出仅供单测钉住口径(`tools/parity/endpoints.mjs` 只按 `method`/`path`/`re`/`handler`
|
|
95
|
+
* 解析本文件,多一个导出不影响端点差集)。
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function pwStr(value) { return typeof value === 'string' ? value : ''; }
|
|
100
|
+
|
|
101
|
+
/** `server.py:_req_str`(577):请求字段字符串规范化,口径与 `pwStr` **逐字相同**(同一个洞) */
|
|
102
|
+
export function reqStr(value) { return typeof value === 'string' ? value : ''; }
|
|
103
|
+
|
|
104
|
+
/** `server.py:USERNAME_RE`(567):2-32 位字母/数字/下划线/点/横杠 */
|
|
105
|
+
const USERNAME_RE = /^[A-Za-z0-9_.\-]{2,32}$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Python `len(str)`:按**码点**计数。
|
|
109
|
+
* 为什么不能写 `text.length`:JS 的 `.length` 数 UTF-16 码元,代理对(emoji 等)算 2,
|
|
110
|
+
* 而 Python 的 emoji 算 1 ⇒ 4 个 emoji 的口令在 Python 是 4 位(不足 6 ⇒ 400),
|
|
111
|
+
* 用 `.length` 会算成 8 位而放行。中文 BMP 字符两边一致,但口径按码点才是逐字对齐。
|
|
112
|
+
*/
|
|
113
|
+
function pyLen(text) { return [...text].length; }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `sqlite3.IntegrityError` 判据 —— 与 `lib/waitq.js:214` 那个**未导出**的 `isIntegrityError`
|
|
117
|
+
* 同口径(该函数没导出、且本批不许改 waitq.js,故在此重写一份;口径若漂移以 waitq.js 为准)。
|
|
118
|
+
*
|
|
119
|
+
* Python `except sqlite3.IntegrityError` 吃的是**整个约束族**(UNIQUE / NOT NULL …),
|
|
120
|
+
* 不只是唯一索引;node:sqlite 对应 `SQLITE_CONSTRAINT`(主码 19)⇒ `errcode & 0xff === 19`。
|
|
121
|
+
* @param {unknown} error
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function isIntegrityError(error) {
|
|
125
|
+
return Boolean(error) && error.code === 'ERR_SQLITE_ERROR'
|
|
126
|
+
&& (Number(error.errcode) & 0xff) === 19;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 登录限流器 —— **进程级单例**,= `server.py:Handler.limiter = auth.LoginLimiter()`(类属性)。
|
|
131
|
+
*
|
|
132
|
+
* 为什么必须模块级:Python 把 limiter 挂在 Handler **类**上 ⇒ 同一进程的所有请求共享同一份
|
|
133
|
+
* 「ip → 尝试时间戳」计数。若挂在 ctx 上或每次 new,判据就从「每 IP 每分钟 10 次」变成
|
|
134
|
+
* 「每请求 10 次」——限流形同不存在,而这是暴力破解的唯一防线。
|
|
135
|
+
*
|
|
136
|
+
* 不放 server.js 的理由:门禁状态归路由族自己持有(server.js 只管时序);单测要独立窗口时
|
|
137
|
+
* 用 `makeLoginHandler({ limiter })` 注入,绝不改这个单例。
|
|
138
|
+
*/
|
|
139
|
+
const LOGIN_LIMITER = new LoginLimiter();
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 登录 handler 的可注入构造(单测用独立窗口/独立 ip)。
|
|
143
|
+
* 生产路由走缺省(模块级单例),见 `LOGIN_LIMITER`。
|
|
144
|
+
* @param {{limiter?: object}} [options]
|
|
145
|
+
* @returns {(ctx: object) => Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
export function makeLoginHandler({ limiter = LOGIN_LIMITER } = {}) {
|
|
148
|
+
return (ctx) => loginHandler(ctx, limiter);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 登录/注册/登出的成功响应(**手工发**,不是 `ctx.json`)。
|
|
153
|
+
*
|
|
154
|
+
* 为什么:Python 这三处也没走 `_respond`(`_api_login`1552-1560、`_api_register`1562-1570、
|
|
155
|
+
* `_api_logout`1522-1530),而是手工 `send_header`——为的是把 Set-Cookie 发在正文之前。
|
|
156
|
+
* 这里四件头与**顺序**逐字照抄:Set-Cookie → Content-Type → Content-Length →
|
|
157
|
+
* Cache-Control: no-store(`respond()` 会把 extraHeaders 排在最后:语义等价、原始字节序不同;
|
|
158
|
+
* 判据是逐字节等价,故不借道它)。
|
|
159
|
+
* @param {object} res node:http ServerResponse(或同形状桩)
|
|
160
|
+
* @param {Buffer|string} body 正文(调用方按 Python 口径序列化好)
|
|
161
|
+
* @param {string} cookie Set-Cookie 整串
|
|
162
|
+
*/
|
|
163
|
+
function respondSessionJson(res, body, cookie) {
|
|
164
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
165
|
+
res.writeHead(200, {
|
|
166
|
+
'Set-Cookie': cookie,
|
|
167
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
168
|
+
'Content-Length': String(buf.length),
|
|
169
|
+
'Cache-Control': 'no-store',
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
res.end(buf);
|
|
173
|
+
} catch {
|
|
174
|
+
// 客户端提前断开:与 `lib/http.js:respond` 同口径吞掉(Python 吞 BrokenPipe)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --------------------------------------------------------------------------
|
|
179
|
+
// 账号族写端点(M6.9):login / register / logout / change_password
|
|
180
|
+
// --------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `_api_login`(1490):公开端点(鉴权之前分派)。顺序**逐条**照 oracle:
|
|
184
|
+
* `limiter.allow(ip)`(在读体**之前**,被拦就直接 429、连体都不读)→ 读体 → 查库 →
|
|
185
|
+
* `limiter.record(ip)`(**无论成败都记**,失败尝试同样计数)→ 校验口令 → 建会话 → 手工发响应。
|
|
186
|
+
*/
|
|
187
|
+
async function loginHandler(ctx, limiter) {
|
|
188
|
+
// Python `self.client_address[0]` 恒有值;Node 的 socket 在测试桩里可能缺席 ⇒ 回落空串
|
|
189
|
+
const ip = ctx.req?.socket?.remoteAddress ?? '';
|
|
190
|
+
if (!limiter.allow(ip)) {
|
|
191
|
+
// 紧凑体(Python 硬编码字节串 `{"error":"尝试过于频繁,请稍后再试"}`)
|
|
192
|
+
ctx.error(429, '尝试过于频繁,请稍后再试');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const body = await ctx.readBody();
|
|
196
|
+
// Python `data.get("username", "")`:缺键/显式 null/非字符串都经 `_req_str` 折成空串
|
|
197
|
+
const username = reqStr(body.username);
|
|
198
|
+
const password = pwStr(body.password);
|
|
199
|
+
const row = dbmod.getUserByName(ctx.db, username);
|
|
200
|
+
limiter.record(ip); // ★ 在查库之后、判口令之前(照 oracle 行序)
|
|
201
|
+
if (row === null || !verifyPassword(password, row.pass_hash, row.salt)) {
|
|
202
|
+
// 用户不存在与口令错误**同一状态码同一文案**(不泄露用户是否存在)
|
|
203
|
+
ctx.error(401, '用户名或密码错误');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const token = newToken();
|
|
207
|
+
dbmod.createSession(ctx.db, row.id, token, sessionExpiry());
|
|
208
|
+
respondSessionJson(ctx.res, jsonBody({
|
|
209
|
+
token,
|
|
210
|
+
username: row.username,
|
|
211
|
+
must_change_password: Boolean(row.must_change_pw),
|
|
212
|
+
}), sessionCookie(token));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `_api_register`(1541):公开端点。三道 400(用户名格式 / 口令长度 / 重名)顺序照 oracle;
|
|
217
|
+
* 成功即自动登录(建会话 + 下发同一个 cookie),正文**只有** `token`/`username` 两个键。
|
|
218
|
+
*/
|
|
219
|
+
async function registerHandler(ctx) {
|
|
220
|
+
const body = await ctx.readBody();
|
|
221
|
+
const username = pyStrip(reqStr(body.username)); // `_req_str(...).strip()`
|
|
222
|
+
const password = pwStr(body.password);
|
|
223
|
+
if (!USERNAME_RE.test(username)) {
|
|
224
|
+
ctx.error(400, '用户名须为 2-32 位字母数字或 _ . -');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (pyLen(password) < 6) {
|
|
228
|
+
ctx.error(400, '密码至少 6 位');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// `auth.md5_password` 的盐**恒为空串**(PBKDF2 那条路只在种子 admin 上用)
|
|
232
|
+
let userId;
|
|
233
|
+
try {
|
|
234
|
+
userId = dbmod.insertUser(username, md5Password(password), '', { db: ctx.db });
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (isIntegrityError(error)) {
|
|
237
|
+
ctx.error(400, '用户名已存在');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
const token = newToken();
|
|
243
|
+
dbmod.createSession(ctx.db, userId, token, sessionExpiry());
|
|
244
|
+
respondSessionJson(ctx.res, jsonBody({ token, username }), sessionCookie(token));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `_api_logout`(1520):**不读请求体**(oracle 该 handler 全文没有 `_read_json`,
|
|
249
|
+
* 且它在 do_POST 的读体那行**之前**分派)。token 可能为 `null`/空串 ⇒ `deleteSession`
|
|
250
|
+
* 删 0 行(Python `db.delete_session(None)` 同)。
|
|
251
|
+
*/
|
|
252
|
+
function logoutHandler(ctx) {
|
|
253
|
+
const token = cookieToken(ctx.req?.headers);
|
|
254
|
+
dbmod.deleteSession(ctx.db, token);
|
|
255
|
+
respondSessionJson(ctx.res, '{"ok":true}', clearSessionCookie());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `_api_change_password`(1576):已登录即可,**不要求 admin**。
|
|
260
|
+
*
|
|
261
|
+
* 两处必须照抄的细节:
|
|
262
|
+
* 1. **handler 自己读体**(`data = self._read_json() or {}`,server.py:1578)——它在
|
|
263
|
+
* do_POST 里虽排在读体行之前分派,但体内自己读(与 logout 不同,见 `./index.js` 的
|
|
264
|
+
* `readBody` 注释);不读 ⇒ 新口令恒空 ⇒ 一律 400「新密码至少 6 位」;
|
|
265
|
+
* 2. **信任头(薄壳免登)下不校验原口令**:插件形态首装的随机一次性口令只印在启动横幅里,
|
|
266
|
+
* 要求原口令等于把面板锁死;该头本就等价 admin 全权(`_current_user`),放行不新增权限面。
|
|
267
|
+
*/
|
|
268
|
+
async function changePasswordHandler(ctx) {
|
|
269
|
+
const user = ctx.user;
|
|
270
|
+
const body = await ctx.readBody();
|
|
271
|
+
const oldPw = pwStr(body.old_password);
|
|
272
|
+
const newPw = pwStr(body.new_password);
|
|
273
|
+
if (!ctx.trusted && !verifyPassword(oldPw, user.pass_hash, user.salt)) {
|
|
274
|
+
ctx.error(400, '原密码错误');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (pyLen(newPw) < 6) {
|
|
278
|
+
ctx.error(400, '新密码至少 6 位');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// `db.change_password` 顺带把 must_change_pw 清 0(改密后强制改密闸自然放行)
|
|
282
|
+
dbmod.changePassword(user.id, md5Password(newPw), '', { db: ctx.db });
|
|
283
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --------------------------------------------------------------------------
|
|
287
|
+
// 管理端用户 CRUD(M6.9):POST /api/admin/users + POST/PATCH/DELETE /api/admin/users/<n>
|
|
288
|
+
// --------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `_api_admin_create_user`(1627):与注册同一三道 400(用户名/口令长度/重名)。
|
|
292
|
+
* 读体在门禁**之前**(Python 是 do_POST:1048 先读体、再分派进本 handler)——
|
|
293
|
+
* 顺序影响「非数字 Content-Length」这类请求:Python 在读体处就断连,不会走到 403。
|
|
294
|
+
*/
|
|
295
|
+
async function adminCreateUser(ctx) {
|
|
296
|
+
const body = await ctx.readBody();
|
|
297
|
+
if (!requireAdmin(ctx)) return;
|
|
298
|
+
const username = pyStrip(reqStr(body.username));
|
|
299
|
+
const password = pwStr(body.password);
|
|
300
|
+
if (!USERNAME_RE.test(username)) {
|
|
301
|
+
ctx.error(400, '用户名须为 2-32 位字母数字或 _ . -');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (pyLen(password) < 6) {
|
|
305
|
+
ctx.error(400, '密码至少 6 位');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let userId;
|
|
309
|
+
try {
|
|
310
|
+
userId = dbmod.insertUser(username, md5Password(password), '', { db: ctx.db });
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (isIntegrityError(error)) {
|
|
313
|
+
ctx.error(400, '用户名已存在');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
ctx.json(200, { id: userId }); // `json.dumps({"id": user_id})` ⇒ **带空格**
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `_api_admin_update_user`(1650):POST 与 PATCH **共用**(oracle 两个分派点都指向它)。
|
|
323
|
+
* admin 账号禁止改名(防止后台管理入口丢失);`password` 缺省/空串 ⇒ 保留存量 hash/salt。
|
|
324
|
+
*/
|
|
325
|
+
async function adminUpdateUser(ctx, userId) {
|
|
326
|
+
const body = await ctx.readBody();
|
|
327
|
+
if (!requireAdmin(ctx)) return;
|
|
328
|
+
const row = dbmod.getUserById(ctx.db, userId);
|
|
329
|
+
if (row === null) {
|
|
330
|
+
ctx.error(404, 'user not found'); // 紧凑体(Python 硬编码字节串)
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Python `_req_str(body.get("username") or row["username"])`:`or` 是 **Python 真值**判定
|
|
334
|
+
// (None/""/0/[]/{} 都取存量)。JS 的 `||` 会把 `[]`/`{}` 当 truthy ⇒ 必须走 pyTruthy,
|
|
335
|
+
// 否则 `{"username": []}` 会变成空串 ⇒ 误回 400(Python 是取存量 ⇒ 200)。
|
|
336
|
+
const username = pyStrip(reqStr(pyTruthy(body.username) ? body.username : row.username));
|
|
337
|
+
if (row.username === 'admin' && username !== 'admin') {
|
|
338
|
+
ctx.error(400, '管理员账号不允许改名');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!USERNAME_RE.test(username)) {
|
|
342
|
+
ctx.error(400, '用户名须为 2-32 位字母数字或 _ . -');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const newPw = pwStr(body.password);
|
|
346
|
+
let passHash = row.pass_hash;
|
|
347
|
+
let salt = row.salt;
|
|
348
|
+
if (newPw !== '') {
|
|
349
|
+
if (pyLen(newPw) < 6) {
|
|
350
|
+
ctx.error(400, '密码至少 6 位'); // 带了口令但太短 ⇒ 不静默忽略
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
passHash = md5Password(newPw);
|
|
354
|
+
salt = '';
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
dbmod.updateUser(userId, username, passHash, salt, { db: ctx.db });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (isIntegrityError(error)) {
|
|
360
|
+
ctx.error(400, '用户名已存在');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `_api_admin_delete_user`(1685):admin 账号与当前登录用户不可删除;成功连带删其会话行。
|
|
370
|
+
* **不读体**(照 `do_DELETE`:1266——Python 整条 DELETE 链没有 `_read_json`)。
|
|
371
|
+
*/
|
|
372
|
+
function adminDeleteUser(ctx, userId) {
|
|
373
|
+
if (!requireAdmin(ctx)) return;
|
|
374
|
+
const row = dbmod.getUserById(ctx.db, userId);
|
|
375
|
+
if (row === null) {
|
|
376
|
+
ctx.error(404, 'user not found');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (row.username === 'admin') {
|
|
380
|
+
ctx.error(400, '管理员账号不允许删除');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Python 这里重取 `self._current_user()`;Node 的 ctx.user 就是鉴权段取好的同一行
|
|
384
|
+
if (ctx.user.id === userId) {
|
|
385
|
+
ctx.error(400, '不能删除当前登录用户');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
dbmod.deleteUser(userId, { db: ctx.db });
|
|
389
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* `_api_admin_rag_get`(1855):RAG 检索配置读取(仅管理员)。
|
|
394
|
+
* api_key **打码回显**(永不回明文);`configured` = 三关键字段齐备(当前是否已生效)。
|
|
395
|
+
*/
|
|
396
|
+
function adminRagGet(ctx) {
|
|
397
|
+
if (!requireAdmin(ctx)) return;
|
|
398
|
+
const raw = rag.raw_config() || {};
|
|
399
|
+
const key = pyStrOrEmpty(raw.api_key);
|
|
400
|
+
const masked = key.length >= 8 ? `****${key.slice(-4)}` : (key ? '****' : '');
|
|
401
|
+
ctx.json(200, {
|
|
402
|
+
configured: rag.load_config() !== null,
|
|
403
|
+
config_path: rag.config_path(),
|
|
404
|
+
api_base: pyStrOrEmpty(raw.api_base),
|
|
405
|
+
model: pyStrOrEmpty(raw.model),
|
|
406
|
+
has_api_key: Boolean(key),
|
|
407
|
+
api_key_masked: masked,
|
|
408
|
+
// Python `raw.get("timeout_s") or rag.DEF_TIMEOUT_S`(0/None 都回落缺省)
|
|
409
|
+
timeout_s: pyTruthy(raw.timeout_s) ? raw.timeout_s : rag.DEF_TIMEOUT_S,
|
|
410
|
+
batch_size: pyTruthy(raw.batch_size) ? raw.batch_size : rag.DEF_BATCH_SIZE,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `_api_admin_rag_set`(1874):RAG 配置保存(仅管理员)。
|
|
416
|
+
* api_key 留空 = 保持现有值;`clear=true` 停用(删配置);校验失败 400 带原因;成功紧凑 `{"ok":true}`。
|
|
417
|
+
*/
|
|
418
|
+
async function adminRagSet(ctx) {
|
|
419
|
+
const body = await ctx.readBody();
|
|
420
|
+
if (!requireAdmin(ctx)) return;
|
|
421
|
+
try {
|
|
422
|
+
if (pyTruthy(body.clear)) {
|
|
423
|
+
rag.disable();
|
|
424
|
+
} else {
|
|
425
|
+
let apiKey = pyStrOrEmpty(body.api_key).trim();
|
|
426
|
+
if (!apiKey) apiKey = pyStrOrEmpty((rag.raw_config() || {}).api_key); // 留空 = 沿用已存 key
|
|
427
|
+
rag.save_config({
|
|
428
|
+
api_base: body.api_base,
|
|
429
|
+
model: body.model,
|
|
430
|
+
api_key: apiKey,
|
|
431
|
+
timeout_s: body.timeout_s,
|
|
432
|
+
batch_size: body.batch_size,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (e instanceof rag.ValueError) {
|
|
437
|
+
ctx.errorSpaced(400, String(e.message)); // Python `str(exc)`
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
throw e;
|
|
441
|
+
}
|
|
442
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* `_api_admin_rag_test`(1896):按表单当前值发一次单文本嵌入探测,**不落盘**;
|
|
447
|
+
* 结果恒 200(`ok=false` 时 error 供前端直接展示)。
|
|
448
|
+
*/
|
|
449
|
+
async function adminRagTest(ctx) {
|
|
450
|
+
const body = await ctx.readBody();
|
|
451
|
+
if (!requireAdmin(ctx)) return;
|
|
452
|
+
let apiKey = pyStrOrEmpty(body.api_key).trim();
|
|
453
|
+
if (!apiKey) apiKey = pyStrOrEmpty((rag.raw_config() || {}).api_key);
|
|
454
|
+
const result = await rag.test_connection({
|
|
455
|
+
api_base: body.api_base,
|
|
456
|
+
model: body.model,
|
|
457
|
+
api_key: apiKey,
|
|
458
|
+
timeout_s: body.timeout_s,
|
|
459
|
+
batch_size: body.batch_size,
|
|
460
|
+
});
|
|
461
|
+
ctx.json(200, result);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export const routes = [
|
|
465
|
+
// ---------- 账号族写四条(M6.9):顺序照 `server.py:do_POST` 的分派先后 ----------
|
|
466
|
+
{
|
|
467
|
+
method: 'POST',
|
|
468
|
+
path: '/api/auth/login',
|
|
469
|
+
/**
|
|
470
|
+
* 公开:`do_POST`(1027) 在 `_current_user()` **之前**分派它——未登录也必须能登录。
|
|
471
|
+
* 名单由 `./index.js:isPublicRoute` 从本标记现算(server.js 不写死字符串)。
|
|
472
|
+
*/
|
|
473
|
+
public: true,
|
|
474
|
+
handler: makeLoginHandler(),
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
method: 'POST',
|
|
478
|
+
path: '/api/auth/register',
|
|
479
|
+
public: true, // 同上:注册也必须在鉴权之前(注册完即登录)
|
|
480
|
+
handler: (ctx) => registerHandler(ctx),
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
method: 'POST',
|
|
484
|
+
path: '/api/auth/logout', // do_POST:1044(已登录;**不读体**)
|
|
485
|
+
handler: (ctx) => logoutHandler(ctx),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
method: 'POST',
|
|
489
|
+
path: '/api/auth/change_password', // do_POST:1047(已登录;handler 内自己读体)
|
|
490
|
+
handler: (ctx) => changePasswordHandler(ctx),
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
method: 'GET',
|
|
494
|
+
path: '/api/auth/me',
|
|
495
|
+
/**
|
|
496
|
+
* 形状逐字对齐 `server.py:_api_me`:`{username, is_admin, must_change_password}`;
|
|
497
|
+
* `must_change_password` 取该行的 `must_change_pw`(Python `bool(user["must_change_pw"])`)。
|
|
498
|
+
*/
|
|
499
|
+
handler: (ctx) => {
|
|
500
|
+
ctx.json(200, {
|
|
501
|
+
username: ctx.user.username,
|
|
502
|
+
is_admin: isAdmin(ctx.user),
|
|
503
|
+
must_change_password: Boolean(ctx.user.must_change_pw),
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
method: 'GET',
|
|
509
|
+
path: '/api/admin/users',
|
|
510
|
+
/** 用户列表(仅管理员)。**不下发** pass_hash/salt 等字段(照抄 Python 的三字段 + is_admin)。 */
|
|
511
|
+
handler: (ctx) => {
|
|
512
|
+
if (!requireAdmin(ctx)) return;
|
|
513
|
+
ctx.json(200, dbmod.listUsers({ db: ctx.db }).map(userAdminJson));
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
// ---------- 管理端用户 CRUD 三条分派点(M6.9):字面路径在正则链之前 ----------
|
|
517
|
+
{
|
|
518
|
+
method: 'POST',
|
|
519
|
+
path: '/api/admin/users', // do_POST:1054
|
|
520
|
+
handler: (ctx) => adminCreateUser(ctx),
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
method: 'POST',
|
|
524
|
+
re: /^\/api\/admin\/users\/(\d+)$/, // do_POST:1075(**POST 也走改号**)
|
|
525
|
+
handler: (ctx, m) => adminUpdateUser(ctx, Number(m[1])),
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
method: 'PATCH',
|
|
529
|
+
re: /^\/api\/admin\/users\/(\d+)$/, // do_PATCH:1252(与 POST 同一 handler)
|
|
530
|
+
handler: (ctx, m) => adminUpdateUser(ctx, Number(m[1])),
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
method: 'DELETE',
|
|
534
|
+
re: /^\/api\/admin\/users\/(\d+)$/, // do_DELETE:1287(**不读体**)
|
|
535
|
+
handler: (ctx, m) => adminDeleteUser(ctx, Number(m[1])),
|
|
536
|
+
},
|
|
537
|
+
// ---------- RAG 配置三条(M6-6):顺序照 Python do_* 链里的先后 ----------
|
|
538
|
+
{
|
|
539
|
+
method: 'POST',
|
|
540
|
+
path: '/api/admin/rag/test', // do_POST:1079
|
|
541
|
+
handler: (ctx) => adminRagTest(ctx),
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
method: 'GET',
|
|
545
|
+
path: '/api/admin/rag', // _route_get:1324
|
|
546
|
+
handler: (ctx) => adminRagGet(ctx),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
method: 'PATCH',
|
|
550
|
+
path: '/api/admin/rag', // do_PATCH:1256
|
|
551
|
+
handler: (ctx) => adminRagSet(ctx),
|
|
552
|
+
},
|
|
553
|
+
];
|