@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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由族:项目推送绑定(飞书群 webhook)——3 个端点(M7)
|
|
3
|
+
*
|
|
4
|
+
* 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头):
|
|
5
|
+
* - `GET /api/projects/<n>/feishu-hook` → `_api_feishu_hook_get`(server.py:2006)
|
|
6
|
+
* - `PATCH /api/projects/<n>/feishu-hook` → `_api_feishu_hook_set`(server.py:2028)
|
|
7
|
+
* - `POST /api/me/feishu-hook/apply` → `_api_me_feishu_hook_apply`(server.py:2061)
|
|
8
|
+
*
|
|
9
|
+
* 三条路由都由 `_owned_project`(2142) / `_current_user()` 收口(多用户隔离红线);
|
|
10
|
+
* 本族前缀与既有七族互不重叠(`/api/projects/<n>/feishu-hook` 不会被
|
|
11
|
+
* `^/api/projects/(\d+)$`、`/board/*`、`/bugs/*` 任何一条吞掉),故挂表顺序不影响分派。
|
|
12
|
+
*
|
|
13
|
+
* ══ 六条「照抄才不失真」的口径(全部是 oracle 逐字实测,不是从本实现反推) ═════════
|
|
14
|
+
* 实测手段:`tests/serverfixture.py` 的隔离实例(临时库 + 随机端口)跑真实 `server.py`,
|
|
15
|
+
* HTTP 级读**原始正文**;数据面另用只读 sqlite 连接直读 `feishu_hooks` 行(证明
|
|
16
|
+
* 「库里的字节」与「回显的顺序」不是同一件事)。
|
|
17
|
+
*
|
|
18
|
+
* 1. **成功体两种字节形态**:`PATCH` 是硬编码字节串 `b'{"ok":true}'`(2059) ⇒ 紧凑、**无空格**,
|
|
19
|
+
* 必须 `ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8')`——全站其它端点的
|
|
20
|
+
* `{"ok": true}` 是 `json.dumps` 口径,本端点**不许**用 `ctx.json`。
|
|
21
|
+
* `apply` 的 `json.dumps({...})`(2108) ⇒ 带空格,走 `ctx.json`。404 `b'{"error":"not found"}'`
|
|
22
|
+
* (2009/2032) 与 400 `'{"error":"events 含非法事件"}'`(2051/2079) 都是硬编码 ⇒ `ctx.error`。
|
|
23
|
+
* 实测:`PATCH → '{"ok":true}'`、`apply → '{"ok": true, "updated": 2, …}'`。
|
|
24
|
+
* 2. **`events` 双口径**:
|
|
25
|
+
* - 写入口径(2050/2054/2077/2102)=请求数组**原序原样、不校验重复**,`",".join(...)` 落库
|
|
26
|
+
* (实测 `["task_failed","task_failed","blocked_interaction"]` ⇒ 库
|
|
27
|
+
* `"task_failed,task_failed,blocked_interaction"`);
|
|
28
|
+
* - 读出口径(2025/2040/2092)=`FEISHU_EVENTS` **固定序 + 去重**,判活是**子串包含**
|
|
29
|
+
* `e in (row["events"] or "")`(实测同一行回显 `["blocked_interaction","task_failed"]`)。
|
|
30
|
+
* ⇒ **响应里的顺序永远不是库里的顺序**。判活走子串包含(白名单是三个互不包含的定值串,
|
|
31
|
+
* 故「前缀误命中」这类问题在本族不存在;`feishu_cfg.js:FEISHU_EVENTS` 与 `feishu.py:31`
|
|
32
|
+
* 逐元素同源)。
|
|
33
|
+
* 3. **`enabled` 真值口径是 Python `bool()`**(2038/2056/2090/2104):`0`/`false`/`""`/`null`/`[]`/`{}`
|
|
34
|
+
* ⇒ 0;`1`/`true`/`"0"`/`"false"`/`"1"`/`"x"` ⇒ 1(**非空字符串一律真**)。JS 的
|
|
35
|
+
* `Boolean([])`/`Boolean({})` 都是 true ⇒ 必须走本文件的 `pyTruthy`。实测 12 档全中。
|
|
36
|
+
* 4. **字符串字段走 `str(v or "").strip()`**(2045/2047/2098/2100):`true`→`"True"`、
|
|
37
|
+
* `["a"]`→`"['a']"`、`{"a":1}`→`"{'a': 1}"`(**Python repr 落库并回显**)、`1.5`→`"1.5"`、
|
|
38
|
+
* `" pad "`→`"pad"`、`""`/`null`/`false`/`0` ⇒ 清空。⇒ 本文件的 `pyStr`/`pyReprString`
|
|
39
|
+
* 是必需的:容器**不能**用 `JSON.stringify` 兜底(那会给出 `["a"]`,与 oracle 差两个引号)。
|
|
40
|
+
* 5. **「缺省=不改」对 `events` 的字节不成立——`PATCH` 与 `apply` 都一样**:`events` 是
|
|
41
|
+
* **每次无条件写**的列(2057-2058 / 2105-2106);没带 `events` 键时写下去的 `cur_events`
|
|
42
|
+
* 来自**读出口径**(`FEISHU_EVENTS` 固定序 + 去重),故未提交的 events 也会被重排/去重回写
|
|
43
|
+
* (2054 的 `cur_events = req` 只在**带键**时才换成请求数组)。隔离实例直读库内字节实测:
|
|
44
|
+
* PATCH 写 `["card_review","task_failed"]` ⇒ 库 `card_review,task_failed`
|
|
45
|
+
* PATCH `{"enabled":true}`(**无** events 键) ⇒ 库 **`task_failed,card_review`**(重排)
|
|
46
|
+
* PATCH 写 `["task_failed","task_failed","blocked_interaction"]`
|
|
47
|
+
* ⇒ 库 `task_failed,task_failed,blocked_interaction`
|
|
48
|
+
* PATCH `{}`(空 body) ⇒ 库 **`blocked_interaction,task_failed`**(去重+重排)
|
|
49
|
+
* 「缺省=不改」**只对 `webhook_url`/`webhook_secret`/`enabled` 三列成立**(实测无键时逐字保留)。
|
|
50
|
+
* 另一处同源行为:无绑定行的项目会被**建出新行**——`PATCH` 用 `FEISHU_EVENTS` 去掉
|
|
51
|
+
* `card_review`(2043),`apply` 用 `FEISHU_DEFAULT_EVENTS`(2096)(两者当前同值,但来源不同)。
|
|
52
|
+
* 6. **归档项目 `GET`/`PATCH` 完全正常**(2006/2028 都只过 `_owned_project`,不看 `archived`);
|
|
53
|
+
* 只有 `apply` 排除它并计入 `archived_skipped`(2084-2086),`updated` 只数被写的未归档项目,
|
|
54
|
+
* `projects` 名单 = `db.list_projects(user_id)` 的 **id 升序**(`db.js:927` 的 `ORDER BY id`
|
|
55
|
+
* =创建序,实测 `["probe_a","probe_b"]`)。
|
|
56
|
+
*
|
|
57
|
+
* ══ 三条「看着能简化、其实一改就分叉」的坑 ═══════════════════════════════════════
|
|
58
|
+
* ① **`ctx.ownedProject` 不能用**:它回 404 `project not found`,而本族 404 文案是 `not found`
|
|
59
|
+
* (`b'{"error":"not found"}'`)。故本文件自带 `ownedProjectQuiet()`——只查行、不写响应,
|
|
60
|
+
* 与 Python `_owned_project`(2142) 逐字对应(同 `routes/projects.js` 的同名助手)。
|
|
61
|
+
* 实测:他人项目 / 不存在 / 非数字 / 负数 / 小数 / 尾斜杠 / 空 id 一律
|
|
62
|
+
* `404 {"error":"not found"}`,与「正则不匹配」的兜底**同形**(不可区分 ⇒ 不泄露存在性)。
|
|
63
|
+
* ② **读体的时机**:`do_PATCH:1262` 在**调用 handler 之前**读体(`self._read_json() or {}`),
|
|
64
|
+
* `do_POST:1048` 给每个 POST 统一读体 ⇒ 本族 `PATCH` 必须**在归属检查之前** `await ctx.readBody()`
|
|
65
|
+
* (404 档也要把 body 读掉)。这不是洁癖:没读掉的 body 会留在 keep-alive 连接上污染下一个
|
|
66
|
+
* 请求(实测踩中——见下方「实测附录」)。`GET` 全程不读体。
|
|
67
|
+
* ③ **无 body / 坏 JSON / 非对象 JSON**(数组、字符串、数字、`null`)一律按 `{}` 处理(5256-5266),
|
|
68
|
+
* **不 400**:实测六档(无 body / `{bad` / `[1,2]` / `"x"` / `null` / 空)全是 200 `{"ok":true}`。
|
|
69
|
+
*
|
|
70
|
+
* ══ 与 Python 的两处**有意**差异(登记,未擅自「修」oracle) ═════════════════════════
|
|
71
|
+
* A. **路径 id ≥ 2^63**(如 `9223372036854775808`):Python 的 `int(m.group(1))` 抛
|
|
72
|
+
* **未捕获 OverflowError** ⇒ 连接被直接掐断、无任何 HTTP 响应(实测 `RemoteDisconnected`)。
|
|
73
|
+
* Node 侧不写畸形代码去复刻这条:`Number()` 得到浮点、`node:sqlite` 绑一个巨大浮点**不抛**
|
|
74
|
+
* (实测直接跑 `DatabaseSync` 绑定 `1e20` 正常返回无匹配),于是落到 `getProject` 无行
|
|
75
|
+
* ⇒ **200 以外的正常 `404 {"error":"not found"}`**。这是「Node 更宽、不做连接层攻击面」的
|
|
76
|
+
* 有意差异,登记在报告里。
|
|
77
|
+
* B. **手工改库把 `events` 列写成非 TEXT**(如整数 5):Python `e in 5` 抛 TypeError ⇒ 断连;
|
|
78
|
+
* 本文件 `String(raw)` 后按子串判 ⇒ 回空集。只有外部改库才可能触发,不在 HTTP 可达面内。
|
|
79
|
+
*
|
|
80
|
+
* ══ 实测附录(隔离实例原文,2026-10-14) ══════════════════════════════════════════
|
|
81
|
+
* PATCH 200 : (200, '{"ok":true}')
|
|
82
|
+
* PATCH 404 : (404, '{"error":"not found"}')
|
|
83
|
+
* enabled=0/false/""/null/[]/{} → 库 0;enabled=1/true/"0"/"false"/"1"/"x" → 库 1
|
|
84
|
+
* url=true → 'True';url=["a"] → "['a']";url={"a":1} → "{'a': 1}";url=0 → ''
|
|
85
|
+
* PATCH ["card_review","task_failed"] ⇒ 库 'card_review,task_failed'
|
|
86
|
+
* PATCH {"enabled":true}(无 events) ⇒ 库 'task_failed,card_review' ← PATCH 也重排
|
|
87
|
+
* PATCH {} ⇒ 库 'blocked_interaction,task_failed'(去重 + 固定序)
|
|
88
|
+
* PATCH ["task_failed","task_failed","blocked_interaction"] ⇒ 库按请求原序原样(写入口径)
|
|
89
|
+
* apply 后库 'task_failed,card_review'(无 events 也重排);归档 skipped=1、updated 只数未归档
|
|
90
|
+
* events 写 `[[]]` / `[{}]` / `["card_review",["x"]]` ⇒ Python `set()` TypeError ⇒ 连接被掐断
|
|
91
|
+
* (RemoteDisconnected);路径 id ≥ 2^63 ⇒ Python OverflowError ⇒ 连接被掐断
|
|
92
|
+
* **未命中路由的 PATCH 不读 body** ⇒ 同一 keep-alive 连接的下一个请求被残留 body 污染
|
|
93
|
+
* (第一版探针就撞上 RemoteDisconnected)。Node 侧未命中兜底**只对 POST** 读体
|
|
94
|
+
* (`server.js:194`),故未命中的 PATCH 与 Python 同形;本族三条路由(含 404 档)总是命中
|
|
95
|
+
* 并读体,不触发这条——登记它只为说明探针为何要开短连接。
|
|
96
|
+
*/
|
|
97
|
+
import { FEISHU_DEFAULT_EVENTS, FEISHU_EVENTS } from '../lib/feishu_cfg.js';
|
|
98
|
+
import * as dbmod from '../lib/db.js';
|
|
99
|
+
import { maskWebhook, pyStrip } from '../lib/lib.js';
|
|
100
|
+
|
|
101
|
+
// ---------- Python 语义助手(本文件私有;与 `routes/projects.js` 的同名助手同口径) ----------
|
|
102
|
+
|
|
103
|
+
/** Python 真值:`""`/`0`/`None`/`False`/空容器为假(JS 的 `[]`/`{}` 是 truthy,必须显式收口) */
|
|
104
|
+
function pyTruthy(value) {
|
|
105
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
106
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
|
|
107
|
+
return Boolean(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Python `repr(str)` 的可用子集(单引号、转义反斜杠/引号/控制字符) */
|
|
111
|
+
function pyReprString(text) {
|
|
112
|
+
let out = "'";
|
|
113
|
+
for (const ch of String(text)) {
|
|
114
|
+
if (ch === '\\') out += '\\\\';
|
|
115
|
+
else if (ch === "'") out += "\\'";
|
|
116
|
+
else if (ch === '\n') out += '\\n';
|
|
117
|
+
else if (ch === '\r') out += '\\r';
|
|
118
|
+
else if (ch === '\t') out += '\\t';
|
|
119
|
+
else if (ch.codePointAt(0) < 0x20 || ch.codePointAt(0) === 0x7f) {
|
|
120
|
+
out += `\\x${ch.codePointAt(0).toString(16).padStart(2, '0')}`;
|
|
121
|
+
} else out += ch;
|
|
122
|
+
}
|
|
123
|
+
return `${out}'`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Python `repr(x)` 的可用子集(str 走 repr 规则,其余用 `pyStr`) */
|
|
127
|
+
function pyRepr(value) {
|
|
128
|
+
if (typeof value === 'string') return pyReprString(value);
|
|
129
|
+
return pyStr(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Python `str(x)`(只覆盖 JSON 里会出现的类型):str→原样;bool→`True`/`False`;null→`None`;
|
|
134
|
+
* 数字→十进制文本;**容器→repr 形态**(`["a"]`⇒`"['a']"`、`{'a': 1}`)。
|
|
135
|
+
*
|
|
136
|
+
* **为什么不用 `JSON.stringify` 兜底**:那给出 `["a"]`/`{"a":1}`,而 oracle 落库并回显的是
|
|
137
|
+
* `['a']`/`{'a': 1}`(实测见文件头第 4 条)——`server.py:2045` 的 `str(...)` 对容器走 repr。
|
|
138
|
+
* 已知残余差异:整数值浮点(JSON `10.0` 在两侧都解析成 10/10.0,取不到原始字面量)⇒ `'10'` vs
|
|
139
|
+
* Python `'10.0'`;与 `lib/builtin-assets.js` 的 `pyStr` 同一条不可消除差异。
|
|
140
|
+
*/
|
|
141
|
+
function pyStr(value) {
|
|
142
|
+
if (typeof value === 'string') return value;
|
|
143
|
+
if (value === null || value === undefined) return 'None';
|
|
144
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
145
|
+
if (typeof value === 'number') return String(value);
|
|
146
|
+
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
147
|
+
if (typeof value === 'object') {
|
|
148
|
+
const items = Object.keys(value).map((k) => `${pyRepr(k)}: ${pyRepr(value[k])}`);
|
|
149
|
+
return `{${items.join(', ')}}`;
|
|
150
|
+
}
|
|
151
|
+
return String(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Python `str(v or "").strip()`(`str()` 永不抛,故非字符串也安全) */
|
|
155
|
+
function pyStripStr(value) {
|
|
156
|
+
return pyStrip(pyStr(pyTruthy(value) ? value : ''));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `dict` 键存在判定:`"k" in body`(值 `null` 也算带键 ⇒ 清空/覆盖,不是「缺省」) */
|
|
160
|
+
function hasKey(body, key) {
|
|
161
|
+
return Object.prototype.hasOwnProperty.call(body, key);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Python `_owned_project`(2142):只返回行、**不写响应**(404 文案由调用方给 `not found`)。
|
|
166
|
+
* 不能用 `ctx.ownedProject`——那条会回 `project not found`(见文件头坑①)。
|
|
167
|
+
*/
|
|
168
|
+
function ownedProjectQuiet(ctx, projectId) {
|
|
169
|
+
const row = dbmod.getProject(projectId, { db: ctx.db });
|
|
170
|
+
if (row === null || row === undefined) return null;
|
|
171
|
+
const user = ctx.user;
|
|
172
|
+
if (user === null || user === undefined || row.user_id !== user.id) return null;
|
|
173
|
+
return row;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `row["events"]` 的**读出口径**(2025):`FEISHU_EVENTS` 固定序 + 去重(子串包含判活) */
|
|
177
|
+
function eventsOfRow(row) {
|
|
178
|
+
const text = pyTruthy(row.events) ? String(row.events) : '';
|
|
179
|
+
return FEISHU_EVENTS.filter((event) => text.includes(event));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 无绑定行时的默认事件集(2017/2043):`FEISHU_EVENTS` 去掉 `card_review`(保序) */
|
|
183
|
+
function defaultEventsNoRow() {
|
|
184
|
+
return FEISHU_EVENTS.filter((event) => event !== 'card_review');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `events` 白名单校验(2050/2077):必须是数组、且每个元素都在 `FEISHU_EVENTS` 里。
|
|
189
|
+
*
|
|
190
|
+
* 非数组 / 元素不在白名单 ⇒ `false`(调用方回 400)。**但元素是容器时 Python 先炸**:
|
|
191
|
+
* `set(req)` 对 list/dict 元素抛 `TypeError: unhashable type` ⇒ 未捕获 ⇒ **连接被掐断、
|
|
192
|
+
* 无 HTTP 响应**(实测 `[[]]`/`[{}]`/`["card_review",["x"]]` 三档全是 `RemoteDisconnected`)。
|
|
193
|
+
* 这里同形抛错(与 `routes/projects.js:pyStripBare`、`lib/db.js:pyIntOf` 同一约定):
|
|
194
|
+
* `dispatchSafely` 把它收口成 `res.destroy()`,即「只断这一条连接」。
|
|
195
|
+
* 注意 `null`/数字/布尔是**可哈希**的 ⇒ 走正常 400 分支(实测 `["task_failed",1]` ⇒ 400)。
|
|
196
|
+
*/
|
|
197
|
+
function eventsAllowed(req) {
|
|
198
|
+
if (!Array.isArray(req)) return false;
|
|
199
|
+
for (const item of req) {
|
|
200
|
+
if (item !== null && typeof item === 'object') {
|
|
201
|
+
throw new TypeError(`unhashable type: '${Array.isArray(item) ? 'list' : 'dict'}'`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return req.every((event) => FEISHU_EVENTS.includes(event));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --------------------------------------------------------------------------
|
|
208
|
+
// GET /api/projects/<n>/feishu-hook —— 读绑定(默认回显 / 打码 / secret 只回布尔)
|
|
209
|
+
// --------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 项目推送绑定读取(`_api_feishu_hook_get` 2006;隔离闸 `_owned_project`)。
|
|
213
|
+
* 无绑定行 ⇒ 200 默认回显(三事件开、`card_review` 关);有行 ⇒ webhook 走
|
|
214
|
+
* `lib.js:maskWebhook`(**码点**口径,>48 码点截断加 `…`),secret 只回布尔。
|
|
215
|
+
* 键序照 Python dict 字面量(webhook_url → has_webhook_secret → enabled → events)——
|
|
216
|
+
* `json.dumps` 保持插入序,键序变了就是字节差。
|
|
217
|
+
*/
|
|
218
|
+
function apiFeishuHookGet(ctx, projectId) {
|
|
219
|
+
if (ownedProjectQuiet(ctx, projectId) === null) {
|
|
220
|
+
ctx.error(404, 'not found');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const row = dbmod.getFeishuHook(projectId, { db: ctx.db });
|
|
224
|
+
if (row === null || row === undefined) {
|
|
225
|
+
ctx.json(200, {
|
|
226
|
+
webhook_url: '',
|
|
227
|
+
has_webhook_secret: false,
|
|
228
|
+
enabled: true,
|
|
229
|
+
events: defaultEventsNoRow(),
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
ctx.json(200, {
|
|
234
|
+
webhook_url: maskWebhook(row.webhook_url),
|
|
235
|
+
has_webhook_secret: pyTruthy(row.webhook_secret),
|
|
236
|
+
enabled: pyTruthy(row.enabled),
|
|
237
|
+
events: eventsOfRow(row),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --------------------------------------------------------------------------
|
|
242
|
+
// PATCH /api/projects/<n>/feishu-hook —— 改绑定(缺省=不改;成功体是字面量)
|
|
243
|
+
// --------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 项目推送绑定更新(`_api_feishu_hook_set` 2028;隔离闸 `_owned_project`)。
|
|
247
|
+
*
|
|
248
|
+
* 「缺省=不改」**只对 `webhook_url`/`webhook_secret`/`enabled` 成立**:`events` 是无条件写的列,
|
|
249
|
+
* body 没带 `events` 键时写下去的是读出口径的结果(固定序 + 去重)⇒ **未提交的 events 也会被
|
|
250
|
+
* 重排/去重回写**(实测见文件头第 5 条)。其余字段「带键即整体覆盖」——空串/null/`false`/`0` = 清除。
|
|
251
|
+
* `events` 非法 ⇒ 400(**此时一行都不写**,实测 11 档行不变)。
|
|
252
|
+
* 成功体是硬编码 `b'{"ok":true}'`(**无空格**)⇒ `ctx.text`,见文件头第 1 条。
|
|
253
|
+
*/
|
|
254
|
+
async function apiFeishuHookSet(ctx, projectId) {
|
|
255
|
+
// 读体必须在归属检查**之前**(`do_PATCH:1262` 的求值序,见文件头坑②)
|
|
256
|
+
const body = await ctx.readBody();
|
|
257
|
+
if (ownedProjectQuiet(ctx, projectId) === null) {
|
|
258
|
+
ctx.error(404, 'not found');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const row = dbmod.getFeishuHook(projectId, { db: ctx.db });
|
|
262
|
+
const hasRow = row !== null && row !== undefined;
|
|
263
|
+
let curUrl = hasRow ? row.webhook_url : '';
|
|
264
|
+
let curSecret = hasRow ? row.webhook_secret : '';
|
|
265
|
+
let curEnabled = hasRow ? pyTruthy(row.enabled) : true;
|
|
266
|
+
let curEvents = hasRow ? eventsOfRow(row) : defaultEventsNoRow();
|
|
267
|
+
if (hasKey(body, 'webhook_url')) curUrl = pyStripStr(body.webhook_url);
|
|
268
|
+
if (hasKey(body, 'webhook_secret')) curSecret = pyStripStr(body.webhook_secret);
|
|
269
|
+
if (hasKey(body, 'events')) {
|
|
270
|
+
if (!eventsAllowed(body.events)) {
|
|
271
|
+
ctx.error(400, 'events 含非法事件');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
curEvents = body.events; // 写入口径:原序原样、不校验重复
|
|
275
|
+
}
|
|
276
|
+
if (hasKey(body, 'enabled')) curEnabled = pyTruthy(body.enabled);
|
|
277
|
+
dbmod.setFeishuHook(projectId, curUrl, curSecret, curEvents.join(','),
|
|
278
|
+
curEnabled ? 1 : 0, { db: ctx.db });
|
|
279
|
+
ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --------------------------------------------------------------------------
|
|
283
|
+
// POST /api/me/feishu-hook/apply —— 批量应用到本人全部未归档项目
|
|
284
|
+
// --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 项目推送绑定**批量应用**(`_api_me_feishu_hook_apply` 2061)。设置页「同时应用到我的
|
|
288
|
+
* 全部项目」:把一份配置写进当前用户**本人的全部未归档项目**(归档项目不再产生推送故不纳入;
|
|
289
|
+
* 跨用户一律不碰——多用户隔离红线,实测他人行一个字节未动)。
|
|
290
|
+
*
|
|
291
|
+
* 字段语义与单项目端点**完全对齐**(缺省=不改该字段;`events` 同样是无条件写的列,
|
|
292
|
+
* 没带键时按读出口径重排 + 去重回写,见文件头第 5 条——实测 `card_review,task_failed`
|
|
293
|
+
* ⇒ `task_failed,card_review`)。无绑定行的项目在这里被**建行**(默认事件集)。
|
|
294
|
+
* 返回 `{ok, updated, archived_skipped, projects}`:`projects` = 实际写入的项目名,
|
|
295
|
+
* 顺序 = `list_projects` 的 id 升序(=创建序),供前端回执列出「改了什么」。
|
|
296
|
+
* `events` 非法 ⇒ 400 且**一行都不写**(含 `archived_skipped` 也不数——整条请求早退)。
|
|
297
|
+
*/
|
|
298
|
+
async function apiMeFeishuHookApply(ctx) {
|
|
299
|
+
const body = await ctx.readBody();
|
|
300
|
+
let reqEvents = null;
|
|
301
|
+
if (hasKey(body, 'events')) {
|
|
302
|
+
if (!eventsAllowed(body.events)) {
|
|
303
|
+
ctx.error(400, 'events 含非法事件');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
reqEvents = body.events;
|
|
307
|
+
}
|
|
308
|
+
const names = [];
|
|
309
|
+
let skipped = 0;
|
|
310
|
+
for (const proj of dbmod.listProjects(ctx.user.id, { db: ctx.db })) {
|
|
311
|
+
if (pyTruthy(proj.archived)) { // 归档项目:不写、只计数
|
|
312
|
+
skipped += 1;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const row = dbmod.getFeishuHook(proj.id, { db: ctx.db });
|
|
316
|
+
const hasRow = row !== null && row !== undefined;
|
|
317
|
+
let curUrl = hasRow ? row.webhook_url : '';
|
|
318
|
+
let curSecret = hasRow ? row.webhook_secret : '';
|
|
319
|
+
let curEnabled = hasRow ? pyTruthy(row.enabled) : true;
|
|
320
|
+
// 无绑定行的默认集与设置页回显一致(`FEISHU_DEFAULT_EVENTS`,2096)
|
|
321
|
+
let curEvents = hasRow ? eventsOfRow(row) : [...FEISHU_DEFAULT_EVENTS];
|
|
322
|
+
if (hasKey(body, 'webhook_url')) curUrl = pyStripStr(body.webhook_url);
|
|
323
|
+
if (hasKey(body, 'webhook_secret')) curSecret = pyStripStr(body.webhook_secret);
|
|
324
|
+
if (reqEvents !== null) curEvents = reqEvents; // is not None:带了键(哪怕空数组)才覆盖
|
|
325
|
+
if (hasKey(body, 'enabled')) curEnabled = pyTruthy(body.enabled);
|
|
326
|
+
dbmod.setFeishuHook(proj.id, curUrl, curSecret, curEvents.join(','),
|
|
327
|
+
curEnabled ? 1 : 0, { db: ctx.db });
|
|
328
|
+
names.push(proj.name);
|
|
329
|
+
}
|
|
330
|
+
ctx.json(200, {
|
|
331
|
+
ok: true, updated: names.length, archived_skipped: skipped, projects: names,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --------------------------------------------------------------------------
|
|
336
|
+
// 路由表(dispatch 先按 method 过滤,故三条互不干扰;前缀与七族不重叠,挂表位置不影响分派)
|
|
337
|
+
// --------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
export const routes = [
|
|
340
|
+
{
|
|
341
|
+
method: 'GET',
|
|
342
|
+
re: /^\/api\/projects\/(\d+)\/feishu-hook$/,
|
|
343
|
+
/** 读绑定(404 文案 `not found`):无行回默认集,有行 webhook 打码、secret 只回布尔。 */
|
|
344
|
+
handler: (ctx, m) => apiFeishuHookGet(ctx, Number(m[1])),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: 'PATCH',
|
|
348
|
+
re: /^\/api\/projects\/(\d+)\/feishu-hook$/,
|
|
349
|
+
/** 改绑定:缺省=不改(仅 url/secret/enabled;events 恒按读出口径重排回写);events 非法 400 不落库。 */
|
|
350
|
+
handler: (ctx, m) => apiFeishuHookSet(ctx, Number(m[1])),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
method: 'POST',
|
|
354
|
+
path: '/api/me/feishu-hook/apply',
|
|
355
|
+
/** 批量应用到本人全部未归档项目:`events` 缺省也会按固定序重排回写(照抄 oracle)。 */
|
|
356
|
+
handler: (ctx) => apiMeFeishuHookApply(ctx),
|
|
357
|
+
},
|
|
358
|
+
];
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由分派 —— `server.py` 的 `do_GET` / `_route_get`(以及 M3 起逐步补齐的
|
|
3
|
+
* `do_POST` / `do_PATCH` / `do_DELETE`)在 Node 侧的对应物。
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么要有这张表,而不是在 server.js 里写 if/elif
|
|
6
|
+
* Python 侧的路由是**顺序敏感**的 if/elif 链:字面路径在前、正则链在后,同一前缀的
|
|
7
|
+
* 「更具体者必须先匹配」(`/api/projects/dup_check` 与 `/api/projects/<id>`、
|
|
8
|
+
* `/board/trash` 与 `/board/<fid>` …)。顺序写错不会报错,只会静默走到错误分支。
|
|
9
|
+
* 把顺序收在**一个**文件里(表即顺序),各族的实现者只往自己的文件里追加条目,
|
|
10
|
+
* 就不会出现「两个人各改一段、合并后顺序变了」这种最难查的错。
|
|
11
|
+
*
|
|
12
|
+
* ## 条目契约(每个 `routes/<族>.js` 导出 `routes` 数组)
|
|
13
|
+
* ```js
|
|
14
|
+
* { method: 'GET', // 缺省 'GET';写路径用 'POST'/'PATCH'/'DELETE'
|
|
15
|
+
* path: '/api/auth/me', // 字面路径(与 re 二选一)
|
|
16
|
+
* re: /^\/api\/projects\/(\d+)$/, // 正则(等价 Python `re.match`,锚定开头)
|
|
17
|
+
* handler: async (ctx, m) => { ... } } // m = 正则匹配结果(无正则时为 null)
|
|
18
|
+
* ```
|
|
19
|
+
* 表内顺序 = 匹配顺序;跨模块的拼接顺序固定为 `admin → projects → tasks → board →
|
|
20
|
+
* sessions`(各族前缀互不重叠,组内保持 Python 的先后即可)。
|
|
21
|
+
*
|
|
22
|
+
* ## `ctx`(handler 的全部输入)
|
|
23
|
+
* - `req` / `res`:`node:http` 原始对象(SSE 要用)
|
|
24
|
+
* - `db`:已打开的数据库句柄(`node:sqlite` DatabaseSync)
|
|
25
|
+
* - `user`:当前用户行(信任头或 cookie 会话;`/api/*` 走到这里必非空)
|
|
26
|
+
* - `trusted`:本次请求是否带薄壳反代注入的免登信任头(`server.py:_trusted_admin_request`)。
|
|
27
|
+
* 等价 admin 全权,故以它为据的放行(`change_password` 免原口令)不新增任何权限面;
|
|
28
|
+
* 缺省 `false`(公开端点分支显式传 `false`)
|
|
29
|
+
* - `path`:**不含查询串**的路径;`query`:`parseQs()` 结果(Python `parse_qs` 口径)
|
|
30
|
+
* - `q(name, def)`:`parsed.get(name, [def])[0]` 的对应物
|
|
31
|
+
* - `json(status, obj)` / `error(status, msg)` / `text(status, body, type)` /
|
|
32
|
+
* `file(path, type)` / `redirect(loc)` / `sse(opts)`:响应写口(口径全在 `lib/http.js`)
|
|
33
|
+
* - `ownedProject(id)` / `ownedTask(id)`:Python `_owned_project` / `_owned_task`,
|
|
34
|
+
* 不存在或越权时**已经回过 404**,返回 `null`,调用方直接 return(多用户隔离红线)
|
|
35
|
+
*
|
|
36
|
+
* ## 数据层调用口径(M3 约定,`lib/db.js` 文件头同步)
|
|
37
|
+
* M3 起的读口是**自由函数 + 末位可选 `{dbPath, db, env}`**:不传就按 `defaultDbPath()`
|
|
38
|
+
* 自开短连接(与 Python `with connect()` 同形),传 `{db}` 则复用调用方的常驻句柄。
|
|
39
|
+
* 路由层一律传 `{ db: ctx.db }`(服务器已开好一个常驻连接,省掉每请求多次 open/close)。
|
|
40
|
+
* M1 的五个 auth 函数(`getUserByName(db, ...)` 等)是**首参句柄**形态,保持不动。
|
|
41
|
+
*/
|
|
42
|
+
import { openSse, readJsonBody, respond, respondError, respondErrorSpaced, respondJson, sendFile,
|
|
43
|
+
sendRedirect } from '../lib/http.js';
|
|
44
|
+
import { agentFamily } from '../lib/agents.js';
|
|
45
|
+
import { StateCache } from '../lib/casescan.js';
|
|
46
|
+
import * as dbmod from '../lib/db.js';
|
|
47
|
+
import { parseQs, qsGet } from '../lib/query.js';
|
|
48
|
+
|
|
49
|
+
import { routes as adminRoutes } from './admin.js';
|
|
50
|
+
import { routes as agentRoutes } from './agents.js';
|
|
51
|
+
import { routes as assetRoutes } from './assets.js';
|
|
52
|
+
import { routes as boardRoutes } from './board.js';
|
|
53
|
+
import { routes as feishuRoutes } from './feishu.js';
|
|
54
|
+
import { routes as feishuAppRoutes } from './feishu_app.js';
|
|
55
|
+
import { routes as feishuHookRoutes } from './feishu_hook.js';
|
|
56
|
+
import { routes as projectRoutes } from './projects.js';
|
|
57
|
+
import { routes as sessionRoutes } from './sessions.js';
|
|
58
|
+
import { routes as taskRoutes } from './tasks.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 全表(顺序即匹配顺序,见文件头)。
|
|
62
|
+
*
|
|
63
|
+
* M6-6 新增两族(`agents` 三条探测 + `builtin-assets` 两条),插在 admin 之后:
|
|
64
|
+
* 位置照 `server.py` 的 `_route_get` 字面路径段(`/api/admin/rag`(1324) →
|
|
65
|
+
* `/api/builtin-assets`(1326) → `/api/agents/*`(1330-1338) → `/api/projects`(1346))。
|
|
66
|
+
* 三族前缀互不重叠,故顺序本身不影响分派结果——照抄只为「表即顺序」这条契约可读。
|
|
67
|
+
*/
|
|
68
|
+
export const ROUTES = [
|
|
69
|
+
...adminRoutes,
|
|
70
|
+
...assetRoutes,
|
|
71
|
+
...agentRoutes,
|
|
72
|
+
...projectRoutes,
|
|
73
|
+
...taskRoutes,
|
|
74
|
+
...boardRoutes,
|
|
75
|
+
...sessionRoutes,
|
|
76
|
+
// M7 追加(`feishu_hook.js` 三条:项目推送绑定)——**只追加、不重排**:
|
|
77
|
+
// 该族前缀(`/api/me/feishu-hook/apply` 字面量 + `/api/projects/<n>/feishu-hook`)与既有
|
|
78
|
+
// 七族任何条目都不重叠(`^/api/projects/(\d+)$`、`/board/*`、`/bugs/*` 都吞不掉它),
|
|
79
|
+
// 故挂表位置对分派结果无影响;放末尾是为了让既有条目的相对顺序一个字节都不动。
|
|
80
|
+
...feishuHookRoutes,
|
|
81
|
+
// M7 续追加(`feishu.js` 个人配置/绑定族 7 条 + `feishu_app.js` 应用面 5 条)——
|
|
82
|
+
// 同样**只追加、不重排**,两条新族与此前八族前缀互不重叠(`/api/me/feishu*` 三个前缀
|
|
83
|
+
// 互不吞并:`-cfg`/`-bindcode` 是字面路径,`/api/me/feishu`、`/api/me/feishu/outbox`、
|
|
84
|
+
// `/api/me/feishu/slash-commands`、`/api/me/feishu/provision`、`/api/me/feishu/doctor`
|
|
85
|
+
// 各由自己的匹配条目收口),故挂表位置对分派结果无影响。
|
|
86
|
+
// 注:车道施工期间这两条暂时是空表(占位文件),全部落地后由本表原样生效。
|
|
87
|
+
...feishuRoutes,
|
|
88
|
+
...feishuAppRoutes,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 本条请求是否命中**公开端点**(在鉴权之前分派,见 `server.js` 的公开端点分支)。
|
|
93
|
+
*
|
|
94
|
+
* 判据由 `ROUTES` 里标了 `public: true` 的条目**现算**(单一真相源):`server.js` 不写死
|
|
95
|
+
* 任何字符串,免得「加了公开端点却忘了改 server.js」这类错误只能靠实例级对拍发现。
|
|
96
|
+
*
|
|
97
|
+
* 对应 oracle `server.py:do_POST`(1027) 的开头两行——`/api/auth/login` 与
|
|
98
|
+
* `/api/auth/register` 在 `_current_user()` **之前**就分派:未登录也必须能打(这正是
|
|
99
|
+
* 「登录」的前提),且**不过强制改密闸**(闸在鉴权之后才调)。
|
|
100
|
+
*
|
|
101
|
+
* 匹配口径与 `dispatch` 完全一致(字面路径精确相等;正则照 `re.match` 只锚开头)。
|
|
102
|
+
* @param {string} method HTTP 方法(大小写不敏感)
|
|
103
|
+
* @param {string} path 不含查询串的路径
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isPublicRoute(method, path) {
|
|
107
|
+
const wanted = String(method || 'GET').toUpperCase();
|
|
108
|
+
for (const route of ROUTES) {
|
|
109
|
+
if (!route.public) continue;
|
|
110
|
+
if ((route.method || 'GET') !== wanted) continue;
|
|
111
|
+
if (route.path !== undefined) {
|
|
112
|
+
if (route.path === path) return true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const match = route.re.exec(path);
|
|
116
|
+
if (match && match.index === 0) return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 全进程唯一的案例库聚合缓存(= `server.py` 的 `server.state_cache`)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么要有这个导出(M4.5 修 M3 遗留):M3 时 `routes/projects.js` 与 `routes/tasks.js`
|
|
125
|
+
* **各建了一个 `StateCache`**(当时注释里已登记「L 把共享实例接进 ctx 之后应改掉」)。
|
|
126
|
+
* 两实例在行为上等价(纯派生 + 1s TTL),但同一进程里同一份盘扫两遍,而 M4.5 的
|
|
127
|
+
* `create_task` 要读 `state.library.stats.total` 当 `cases_base` 基线——三处消费者共用一份
|
|
128
|
+
* 缓存才是 Python 的形态。写口唯一:`makeContext` 的 `stateCache` 参数(server.js 传入)。
|
|
129
|
+
*/
|
|
130
|
+
export const STATE_CACHE = new StateCache();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 构造一次请求的 ctx(见文件头契约)。
|
|
134
|
+
* @param {{req: object, res: object, db: object, user: object, path: string, search: string,
|
|
135
|
+
* stateCache?: object, trusted?: boolean}} parts
|
|
136
|
+
*/
|
|
137
|
+
export function makeContext({ req, res, db, user, path, search, stateCache = STATE_CACHE,
|
|
138
|
+
trusted = false }) {
|
|
139
|
+
const query = parseQs(search);
|
|
140
|
+
let bodyPromise = null;
|
|
141
|
+
const ctx = {
|
|
142
|
+
req,
|
|
143
|
+
res,
|
|
144
|
+
db,
|
|
145
|
+
user,
|
|
146
|
+
trusted,
|
|
147
|
+
path,
|
|
148
|
+
query,
|
|
149
|
+
q: (name, def = '') => qsGet(query, name, def),
|
|
150
|
+
stateCache,
|
|
151
|
+
/**
|
|
152
|
+
* 读请求体(记忆化:一次请求只读一次,重复调用返回**同一个对象**)。
|
|
153
|
+
*
|
|
154
|
+
* 位置必须与 Python `do_POST` 的 `body = self._read_json() or {}`(server.py:1048)
|
|
155
|
+
* 对齐——不是「想读就读」:
|
|
156
|
+
* - `/api/auth/logout`(1520) 在 Python 里**排在读体那行之前**且 handler 内部也不读,
|
|
157
|
+
* 所以**不许调它**(`_api_logout` 全文没有 `_read_json`);
|
|
158
|
+
* - `/api/auth/change_password`(1576) 虽然在读体那行之前分派,但**由 handler 自己读体**
|
|
159
|
+
* (`_api_change_password` 里 `data = self._read_json() or {}`,server.py:1578)——
|
|
160
|
+
* 照 Python 的同一位置在 handler 内调用它。
|
|
161
|
+
* (2026-10-14 订正:本注释早先把这两条都写成「不读体」,与 oracle 不符;
|
|
162
|
+
* 不读的后果是确定性的:新口令恒空 ⇒ 一律 400「新密码至少 6 位」。)
|
|
163
|
+
*
|
|
164
|
+
* 用法:`const body = await ctx.readBody();`(错误体两种形态的选择见 `ctx.error` 注释)。
|
|
165
|
+
* **必须 await**:`lib/http.js:readJsonBody` 的异常(如非数字 Content-Length)若逃逸,
|
|
166
|
+
* 会由 `dispatchSafely` 掐断这条连接——与 Python 抛 ValueError 只断该连接同形。
|
|
167
|
+
*/
|
|
168
|
+
readBody: () => {
|
|
169
|
+
if (bodyPromise === null) bodyPromise = readJsonBody(req);
|
|
170
|
+
return bodyPromise;
|
|
171
|
+
},
|
|
172
|
+
json: (status, value) => respondJson(res, status, value),
|
|
173
|
+
// 错误体两种形态,按 Python 该写点的实际写法选(不能统一!见 lib/http.js 的说明):
|
|
174
|
+
// error = 硬编码字节串口径 `{"error":"..."}`(无空格,Python 里 112 处)
|
|
175
|
+
// errorSpaced = `json.dumps({"error": ...})` 口径 `{"error": "..."}`(带空格,49 处)
|
|
176
|
+
error: (status, message) => respondError(res, status, message),
|
|
177
|
+
errorSpaced: (status, message) => respondErrorSpaced(res, status, message),
|
|
178
|
+
text: (status, body, type = 'text/plain; charset=utf-8', extraHeaders = null) =>
|
|
179
|
+
respond(res, status, body, type, extraHeaders || undefined),
|
|
180
|
+
file: (filePath, type) => sendFile(res, filePath, type),
|
|
181
|
+
redirect: (location) => sendRedirect(res, location),
|
|
182
|
+
sse: (opts) => openSse(res, opts),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** `server.py:_owned_project`:非本人或不存在的项目一律 404 `project not found` */
|
|
186
|
+
ctx.ownedProject = (projectId) => {
|
|
187
|
+
// db.js 读口签名(M3 约定):自由函数 + 末位可选 `{db}`;传句柄即复用服务器的常驻连接
|
|
188
|
+
const row = dbmod.getProject(projectId, { db });
|
|
189
|
+
if (row === null || row === undefined || row.user_id !== user.id) {
|
|
190
|
+
ctx.error(404, 'project not found');
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return row;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `server.py:_owned_task`:任务不存在或其项目不归当前用户 ⇒ 404 `task not found`。
|
|
198
|
+
*
|
|
199
|
+
* **不能复用 `ctx.ownedProject`**(M3 实测踩中,由任务族实现者抓出):Python 的
|
|
200
|
+
* `_owned_project`(server.py:2142)**没有响应副作用**,只 `return None`;越权时
|
|
201
|
+
* `_owned_task` 返回 None,由 `_api_get_task` 统一回 `task not found`。若这里借道
|
|
202
|
+
* 会回 404 的 `ownedProject`,就会发出 `project not found` —— 既与 Python 文案不同
|
|
203
|
+
* (对拍红),也让越权者能从文案差异反推「该任务确实存在」(**信息泄露**)。
|
|
204
|
+
* 这里显式重做归属判定,只回 `task not found` 这一种文案。
|
|
205
|
+
*/
|
|
206
|
+
ctx.ownedTask = (taskId) => {
|
|
207
|
+
const row = dbmod.getTask(taskId, { db });
|
|
208
|
+
if (row === null || row === undefined) {
|
|
209
|
+
ctx.error(404, 'task not found');
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const owner = dbmod.getProject(row.project_id, { db });
|
|
213
|
+
if (owner === null || owner === undefined || owner.user_id !== user.id) {
|
|
214
|
+
ctx.error(404, 'task not found');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return row;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `server.py:_session_context`(4738):session 接口公共上下文 =(任务行, 项目行, agent 归族)。
|
|
222
|
+
*
|
|
223
|
+
* **无响应副作用**(与 `_owned_task` 同款):越权/不存在时三空,由调用方自己回 404
|
|
224
|
+
* `task not found`——各会话端点的文案与时机不同,不许在这里统一回。
|
|
225
|
+
*
|
|
226
|
+
* `family` 的缺省值是 `'dsh_plugin'`(照 Python:项目行拿不到时也返回默认族)。
|
|
227
|
+
* 归属判定走 `ownedTask` 吗?**不走**:`_session_context` 内部就是 `self._owned_task()`,
|
|
228
|
+
* 而 `ctx.ownedTask` 会回 404 —— 那会让「越权」变成「已响应」,各端点随后再回一次
|
|
229
|
+
* (同一响应上二次 writeHead ⇒ Node 抛 `ERR_HTTP_HEADERS_SENT`)。这里复刻纯判定。
|
|
230
|
+
*/
|
|
231
|
+
ctx.sessionContext = (taskId) => {
|
|
232
|
+
const task = dbmod.getTask(taskId, { db });
|
|
233
|
+
if (task === null || task === undefined) return { task: null, project: null, family: null };
|
|
234
|
+
const project = dbmod.getProject(task.project_id, { db });
|
|
235
|
+
if (project === null || project === undefined || project.user_id !== user.id) {
|
|
236
|
+
return { task: null, project: null, family: null };
|
|
237
|
+
}
|
|
238
|
+
return { task, project, family: agentFamily(project.agent_path) };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return ctx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 按表分派。命中并已响应返回 `true`;无命中返回 `false`(由调用方决定 404/静态回退)。
|
|
246
|
+
*
|
|
247
|
+
* 只在 `dispatchSafely` 内部使用;直接调用方要自己承担处理器异常(会变成
|
|
248
|
+
* unhandledRejection 杀掉进程,见 `dispatchSafely`)。
|
|
249
|
+
*/
|
|
250
|
+
export async function dispatch(ctx) {
|
|
251
|
+
const method = String(ctx.req.method || 'GET').toUpperCase();
|
|
252
|
+
for (const route of ROUTES) {
|
|
253
|
+
if ((route.method || 'GET') !== method) continue;
|
|
254
|
+
let match = null;
|
|
255
|
+
if (route.path !== undefined) {
|
|
256
|
+
if (route.path !== ctx.path) continue;
|
|
257
|
+
} else {
|
|
258
|
+
match = route.re.exec(ctx.path);
|
|
259
|
+
// Python `re.match` 只锚定开头;表里模式都以 ^ 起头,index!==0 视作不匹配
|
|
260
|
+
if (!match || match.index !== 0) continue;
|
|
261
|
+
}
|
|
262
|
+
await route.handler(ctx, match);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `dispatch` 的**带兜底**版本(`server.js` 用这个):处理器抛异常时只关掉这一条连接,
|
|
270
|
+
* 服务继续跑。
|
|
271
|
+
*
|
|
272
|
+
* 为什么必须有(M3 实测,任务族实现者抓出):Python 侧处理器抛异常时,`socketserver`
|
|
273
|
+
* 的 `handle_error` 打一条 traceback 后 `shutdown_request` —— **只断这一条连接**,
|
|
274
|
+
* 同一进程的后续请求照常 200。Node 侧若不兜,handler 里的异常会变成
|
|
275
|
+
* `unhandledRejection` **杀掉整个后端进程**(实测:一个 `ReferenceError` ⇒ 进程没了 ⇒
|
|
276
|
+
* 之后所有请求 connection refused)。这是「一个坏请求打挂整个站点」的可用性差异;
|
|
277
|
+
* M4/M5 写路径上线后只会更容易触发,「已响应后再写」抛的 `ERR_HTTP_HEADERS_SENT`
|
|
278
|
+
* 也走这条路。
|
|
279
|
+
*
|
|
280
|
+
* `res.destroy()` 对应 Python 的「掐断这条连接」:**不补发 500**(Python 也不发——
|
|
281
|
+
* 异常路径走不到 `send_error`),客户端看到连接层错误,两版同形。
|
|
282
|
+
*/
|
|
283
|
+
export async function dispatchSafely(ctx, { logger = console } = {}) {
|
|
284
|
+
try {
|
|
285
|
+
return await dispatch(ctx);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// 留痕:没有这行日志,线上只会看到「请求莫名失败」,排障成本极高
|
|
288
|
+
logger.error('touchstone-node: 处理器异常(只关这一条连接,服务继续): '
|
|
289
|
+
+ `${(error && error.stack) || error}`);
|
|
290
|
+
try {
|
|
291
|
+
ctx.res?.destroy?.();
|
|
292
|
+
} catch {
|
|
293
|
+
// 连接可能已断:忽略
|
|
294
|
+
}
|
|
295
|
+
return true; // 已接管这次请求(不会再补响应)
|
|
296
|
+
}
|
|
297
|
+
}
|