@sleep2agi/commhub-server 0.9.0-preview.144 → 0.9.0-preview.145
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/package.json +1 -1
- package/src/db.ts +15 -0
- package/src/node-lifecycle-read-http.test.ts +5 -0
- package/src/node-lifecycle-read.ts +3 -0
- package/src/node-session-control-http.test.ts +308 -0
- package/src/node-session-control.ts +254 -0
- package/src/server.ts +145 -1
- package/src/user-avatar-image.ts +153 -0
- package/src/user-avatar-upload-http.test.ts +294 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.145",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -322,6 +322,21 @@ db.exec(`
|
|
|
322
322
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
323
323
|
`);
|
|
324
324
|
|
|
325
|
+
// 账号头像上传(二进制 PNG,Hub 自己发 URL)。与 users.avatar_url 并存:
|
|
326
|
+
// avatar_url 仍可指向内建池/外链;这张表只存「上传到 Hub 本人的那张」。
|
|
327
|
+
// 单行/人:新版本覆盖旧版本,旧 URL 立刻 404(version 变了),不做历史留存。
|
|
328
|
+
db.exec(`
|
|
329
|
+
CREATE TABLE IF NOT EXISTS user_avatar_uploads (
|
|
330
|
+
user_id TEXT PRIMARY KEY,
|
|
331
|
+
version TEXT NOT NULL,
|
|
332
|
+
png BLOB NOT NULL,
|
|
333
|
+
bytes INTEGER NOT NULL,
|
|
334
|
+
width INTEGER NOT NULL,
|
|
335
|
+
height INTEGER NOT NULL,
|
|
336
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
337
|
+
);
|
|
338
|
+
`);
|
|
339
|
+
|
|
325
340
|
// Human profile avatar. Additive for existing databases; old binaries ignore it.
|
|
326
341
|
try {
|
|
327
342
|
db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT");
|
|
@@ -66,8 +66,13 @@ test("public errors redact free text for granted viewers on every projection", a
|
|
|
66
66
|
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
67
67
|
"adopt_codex_identity_unproven",
|
|
68
68
|
"TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
|
|
69
|
+
// #2012:daemon 明确拒绝的固定安全码,必须原样投影给用户,而不是退化成 lifecycle_error。
|
|
70
|
+
"copresence_adopt_v2",
|
|
71
|
+
// 负控:带后缀/路径/空白的形状仍必须清成 lifecycle_error(只精确放行这一串)。
|
|
72
|
+
"copresence_adopt_v2: /private/fixture", "copresence_adopt_v2x", " copresence_adopt_v2",
|
|
69
73
|
]) {
|
|
70
74
|
const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
|
|
75
|
+
"copresence_adopt_v2",
|
|
71
76
|
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
72
77
|
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
73
78
|
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
@@ -33,6 +33,9 @@ const publicLifecycleErrors = new Set([
|
|
|
33
33
|
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
34
34
|
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
35
35
|
"opencode_tui_session_mismatch",
|
|
36
|
+
// #2012 —— daemon 的 copresence_adopt_v2 拒绝是固定安全码(无路径/无异常文本),
|
|
37
|
+
// 不放行会让用户投影退化成 lifecycle_error,看不到「为什么被拒绝」。
|
|
38
|
+
"copresence_adopt_v2",
|
|
36
39
|
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
37
40
|
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
38
41
|
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setSystemTime, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-session-control-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
const root = "/api/node-session-control";
|
|
9
|
+
let server: any, base: string, db: any, net: string, otherNet: string;
|
|
10
|
+
let owner: any, admin: any, member: any, outsider: any, ntok: any, peer: any;
|
|
11
|
+
let legacyOwner: any, legacyAdmin: any, legacyMember: any, legacyOutsider: any, legacyFull: any, legacyNetwork: any;
|
|
12
|
+
let binding = { session_id: "session_1", binding_id: "binding_1" }, serial = 0;
|
|
13
|
+
const nodeId = "n_session_control", peerId = "n_session_control_peer";
|
|
14
|
+
async function api(token: string, path: string, value?: unknown, method?: string) {
|
|
15
|
+
const res = await fetch(`${base}${path}`, { method: method ?? (value === undefined ? "GET" : "POST"),
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
17
|
+
...(value === undefined ? {} : { body: JSON.stringify(value) }) });
|
|
18
|
+
return { status: res.status, body: await res.json() as any };
|
|
19
|
+
}
|
|
20
|
+
const registerBridge = (v = binding, token = ntok.token!) => api(token, `${root}/bridge`,
|
|
21
|
+
{ ...v, expected: { node_id: nodeId, network_id: net } });
|
|
22
|
+
const enqueue = (operation = "background", extra = {}) => api(owner.token!, root,
|
|
23
|
+
{ network_id: net, node_id: nodeId, operation, expected: binding, ...extra });
|
|
24
|
+
const pull = (token = ntok.token!, v = binding) => api(token, `${root}/pull`, v);
|
|
25
|
+
const read = (request: string, token = owner.token!, network = net) => api(token, `${root}/${request}?network_id=${network}`);
|
|
26
|
+
const state = () => ({ ...binding, supported: true, active: true, can_request_background: true, observed_at: Date.now() });
|
|
27
|
+
const ack = (request: string, result: unknown, extra = {}) => api(ntok.token!, `${root}/${request}/ack`,
|
|
28
|
+
{ ...binding, status: "done", result, ...extra });
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
({ db } = await import("./db.js"));
|
|
32
|
+
const { register, addNetworkMember, createNetworkTokenForNode, createToken } = await import("./auth.js");
|
|
33
|
+
const stamp = Date.now();
|
|
34
|
+
admin = register(`sc_admin_${stamp}`, "SessionControl-Test-1!");
|
|
35
|
+
owner = register(`sc_owner_${stamp}`, "SessionControl-Test-2!");
|
|
36
|
+
member = register(`sc_member_${stamp}`, "SessionControl-Test-3!");
|
|
37
|
+
outsider = register(`sc_other_${stamp}`, "SessionControl-Test-4!");
|
|
38
|
+
expect(admin.ok && owner.ok && member.ok && outsider.ok).toBe(true);
|
|
39
|
+
net = admin.network_id!; otherNet = outsider.network_id!;
|
|
40
|
+
for (const u of [owner, member]) expect(addNetworkMember(net, u.user.user_id, "member", admin.user.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
41
|
+
// Real atok hashes via the legacy issuer; only fixture rows are converted
|
|
42
|
+
// to the historical global scope=user shape. No prefix-based authority.
|
|
43
|
+
const legacyUser = (u: any) => {
|
|
44
|
+
const t = createToken(u.user.user_id, "sc-legacy-user");
|
|
45
|
+
expect(t.ok).toBe(true);
|
|
46
|
+
expect(t.token).toMatch(/^atok_/);
|
|
47
|
+
db.run("UPDATE api_tokens SET scope='user' WHERE token_id=?1", t.token_id);
|
|
48
|
+
return t;
|
|
49
|
+
};
|
|
50
|
+
legacyOwner = legacyUser(owner); legacyAdmin = legacyUser(admin);
|
|
51
|
+
legacyMember = legacyUser(member); legacyOutsider = legacyUser(outsider);
|
|
52
|
+
legacyFull = createToken(owner.user.user_id, "sc-legacy-global-full");
|
|
53
|
+
legacyNetwork = createToken(owner.user.user_id, "sc-legacy-network", net);
|
|
54
|
+
expect(legacyFull.ok && legacyNetwork.ok).toBe(true);
|
|
55
|
+
ntok = createNetworkTokenForNode(owner.user.user_id, net, "sc-target", nodeId);
|
|
56
|
+
peer = createNetworkTokenForNode(owner.user.user_id, net, "sc-peer", peerId);
|
|
57
|
+
expect(ntok.ok && peer.ok).toBe(true);
|
|
58
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status) VALUES (?1,?2,?3,?4,'idle')", ["r_sc_target", "sc-target", nodeId, net]);
|
|
59
|
+
const { bootServer } = await import("./server.js");
|
|
60
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
61
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
62
|
+
}, 30_000);
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
binding = { session_id: `session_${++serial}`, binding_id: `binding_${serial}` };
|
|
65
|
+
expect((await registerBridge()).status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => setSystemTime());
|
|
68
|
+
afterAll(() => { server?.stop(true); rmSync(dir, { recursive: true, force: true }); });
|
|
69
|
+
|
|
70
|
+
describe("#2017 human-to-exact-Bridge HTTP relay", () => {
|
|
71
|
+
test("[2017 identity] missing or malformed expected is refused without fallback", async () => {
|
|
72
|
+
for (const expected of [undefined, {}, { node_id: nodeId }, { node_id: nodeId, network_id: net, token_id: ntok.token_id }]) {
|
|
73
|
+
const rejected = await api(ntok.token!, `${root}/bridge`, { ...binding, expected });
|
|
74
|
+
expect(rejected.status).toBe(400);
|
|
75
|
+
expect(rejected.body.error).toBe("session_control_request_invalid");
|
|
76
|
+
}
|
|
77
|
+
expect((await registerBridge()).status).toBe(200);
|
|
78
|
+
});
|
|
79
|
+
test("[2017 identity] A credential expected B cannot sweep, replace or retire the valid binding", async () => {
|
|
80
|
+
const made = await enqueue("state"), clock = Date.now();
|
|
81
|
+
const tokenBefore = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
|
|
82
|
+
const nodeBefore = db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId);
|
|
83
|
+
// Advancing beyond the request deadline exposes an accidental sweep before
|
|
84
|
+
// rejecting expected. Restore only the clock, never the authoritative queue.
|
|
85
|
+
setSystemTime(new Date(clock + 16_000));
|
|
86
|
+
for (const expected of [{ node_id: peerId, network_id: net }, { node_id: nodeId, network_id: otherNet }]) {
|
|
87
|
+
const rejected = await api(ntok.token!, `${root}/bridge`, {
|
|
88
|
+
session_id: "would_replace_session", binding_id: "would_retire_binding", expected,
|
|
89
|
+
});
|
|
90
|
+
expect(rejected.status).toBe(409);
|
|
91
|
+
expect(rejected.body.error).toBe("session_control_changed");
|
|
92
|
+
}
|
|
93
|
+
setSystemTime(new Date(clock));
|
|
94
|
+
expect((await read(made.body.request_id)).body.status).toBe("pending");
|
|
95
|
+
expect((await registerBridge()).status).toBe(200); // Original generation was not retired.
|
|
96
|
+
expect((await pull()).body.request).toMatchObject({ request_id: made.body.request_id, ...binding });
|
|
97
|
+
expect(db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id)).toEqual(tokenBefore);
|
|
98
|
+
expect(db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId)).toEqual(nodeBefore);
|
|
99
|
+
});
|
|
100
|
+
test("[2017 identity] success carries the authenticated token row identity, never credential material", async () => {
|
|
101
|
+
const response = await registerBridge();
|
|
102
|
+
const row = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
|
|
103
|
+
expect(response.status).toBe(200);
|
|
104
|
+
expect(response.body.identity).toEqual({ node_id: row.bound_node_id, network_id: row.network_id, token_id: row.token_id });
|
|
105
|
+
expect(Object.keys(response.body).sort()).toEqual(["expires_at", "identity", "ok"]);
|
|
106
|
+
expect(JSON.stringify(response.body)).not.toContain(ntok.token);
|
|
107
|
+
});
|
|
108
|
+
test("normal state and background receipts are separate from actual tool completion", async () => {
|
|
109
|
+
const s = await enqueue("state");
|
|
110
|
+
expect(s.body).toMatchObject({ ok: true, status: "pending" });
|
|
111
|
+
expect(s.body.request_id).toMatch(/^sc_\d{13}_[A-Za-z0-9_-]{8,64}$/);
|
|
112
|
+
expect((await pull()).body.request).toMatchObject({ request_id: s.body.request_id, operation: "state", ...binding });
|
|
113
|
+
const observed = state();
|
|
114
|
+
expect((await ack(s.body.request_id, observed)).body.status).toBe("done");
|
|
115
|
+
expect((await read(s.body.request_id)).body.result).toEqual(observed);
|
|
116
|
+
const b = await enqueue();
|
|
117
|
+
expect(b.body.request_id).toMatch(/^bg_\d{13}_[A-Za-z0-9_-]{8,64}$/);
|
|
118
|
+
expect(b.body.expires_at).toBeGreaterThan(Date.now());
|
|
119
|
+
await pull();
|
|
120
|
+
const result = { outcome: "request_processed", state: state() };
|
|
121
|
+
expect((await ack(b.body.request_id, result)).body.status).toBe("done");
|
|
122
|
+
expect((await read(b.body.request_id)).body).toMatchObject({ ok: true, status: "done", result });
|
|
123
|
+
});
|
|
124
|
+
test("[2017 review] already_idle cannot confirm an active or background-capable state", async () => {
|
|
125
|
+
for (const flags of [
|
|
126
|
+
{ active: true, can_request_background: true },
|
|
127
|
+
{ active: true, can_request_background: false },
|
|
128
|
+
{ active: false, can_request_background: true },
|
|
129
|
+
]) {
|
|
130
|
+
const made = await enqueue(); await pull();
|
|
131
|
+
const reply = await ack(made.body.request_id, { outcome: "already_idle", state: { ...state(), ...flags } });
|
|
132
|
+
expect(reply.status).toBe(400);
|
|
133
|
+
expect(reply.body.error).toBe("session_control_invalid_response");
|
|
134
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("[2017 review] legacy user atok owner/admin and global full can create and read control", async () => {
|
|
138
|
+
for (const t of [legacyOwner, legacyAdmin, legacyFull]) {
|
|
139
|
+
for (const operation of ["state", "background"]) {
|
|
140
|
+
const made = await api(t.token, root, { network_id: net, node_id: nodeId, operation, expected: binding });
|
|
141
|
+
expect(made.status).toBe(200);
|
|
142
|
+
expect((await read(made.body.request_id, t.token)).body.status).toBe("pending");
|
|
143
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
144
|
+
const result = operation === "state" ? state() : { outcome: "request_processed", state: state() };
|
|
145
|
+
expect((await ack(made.body.request_id, result)).body.status).toBe("done");
|
|
146
|
+
expect((await read(made.body.request_id, t.token)).body.result).toEqual(result);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
test("[2017 review] legacy atok still requires current target ownership and exact network", async () => {
|
|
151
|
+
for (const t of [legacyMember, legacyOutsider]) {
|
|
152
|
+
expect((await api(t.token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBe(403);
|
|
153
|
+
}
|
|
154
|
+
expect((await api(legacyOwner.token, root, { network_id: otherNet, node_id: nodeId, operation: "state" })).status).toBe(403);
|
|
155
|
+
const made = await enqueue("state");
|
|
156
|
+
for (const t of [legacyMember, legacyOutsider]) expect((await read(made.body.request_id, t.token)).status).toBe(403);
|
|
157
|
+
expect((await read(made.body.request_id, legacyOwner.token, otherNet)).status).toBe(403);
|
|
158
|
+
});
|
|
159
|
+
test("[2017 review] legacy atok user and unbound network credentials cannot impersonate Bridge", async () => {
|
|
160
|
+
const made = await enqueue("state");
|
|
161
|
+
for (const t of [legacyOwner, legacyAdmin, legacyFull, legacyNetwork]) {
|
|
162
|
+
expect((await registerBridge(binding, t.token)).status).toBe(403);
|
|
163
|
+
expect((await pull(t.token)).status).toBe(403);
|
|
164
|
+
expect((await api(t.token, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(403);
|
|
165
|
+
}
|
|
166
|
+
expect((await read(made.body.request_id)).body.status).toBe("pending");
|
|
167
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
168
|
+
});
|
|
169
|
+
test("[2017 review] unknown, revoked, expired or unsupported-scope credentials stay refused", async () => {
|
|
170
|
+
const request = () => api(legacyOwner.token, root, { network_id: net, node_id: nodeId, operation: "state" });
|
|
171
|
+
expect((await api("atok_00000000000000000000000000000000", root, {})).status).toBe(401);
|
|
172
|
+
for (const patch of ["revoked_at=datetime('now')", "expires_at=datetime('now','-1 day')", "scope='unsupported'"]) {
|
|
173
|
+
db.run(`UPDATE api_tokens SET ${patch} WHERE token_id=?1`, legacyOwner.token_id);
|
|
174
|
+
try { expect((await request()).status).toBeGreaterThanOrEqual(400); }
|
|
175
|
+
finally { db.run("UPDATE api_tokens SET revoked_at=NULL,expires_at=NULL,scope='user' WHERE token_id=?1", legacyOwner.token_id); }
|
|
176
|
+
}
|
|
177
|
+
expect((await request()).status).toBe(200);
|
|
178
|
+
});
|
|
179
|
+
test("SSE is a scoped doorbell only; the request lives in pull", async () => {
|
|
180
|
+
const abort = new AbortController();
|
|
181
|
+
const response = await fetch(`${base}/events/sc-target`, { headers: { authorization: `Bearer ${ntok.token}` }, signal: abort.signal });
|
|
182
|
+
expect(response.status).toBe(200);
|
|
183
|
+
const reader = response.body!.getReader();
|
|
184
|
+
try {
|
|
185
|
+
const made = await enqueue();
|
|
186
|
+
let data = "";
|
|
187
|
+
for (let i = 0; i < 4 && !data.includes('"type":"session_control"'); i++) {
|
|
188
|
+
const next = await reader.read(); data += new TextDecoder().decode(next.value);
|
|
189
|
+
}
|
|
190
|
+
const event = data.split("\n").filter(s => s.startsWith("data: ")).map(s => JSON.parse(s.slice(6))).find(s => s.type === "session_control");
|
|
191
|
+
expect(event).toEqual({ type: "session_control", request_id: made.body.request_id });
|
|
192
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
193
|
+
} finally { abort.abort(); await reader.cancel().catch(() => {}); }
|
|
194
|
+
});
|
|
195
|
+
test("node, peer, anonymous, master and member cannot impersonate human owner/admin", async () => {
|
|
196
|
+
for (const token of [ntok.token, peer.token, "", "legacy-test-only", member.token, outsider.token]) {
|
|
197
|
+
expect((await api(token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBeGreaterThanOrEqual(400);
|
|
198
|
+
}
|
|
199
|
+
const adminMade = await api(admin.token!, root, { network_id: net, node_id: nodeId, operation: "state" });
|
|
200
|
+
expect(adminMade.status).toBe(200);
|
|
201
|
+
expect((await read(adminMade.body.request_id, member.token!)).status).toBe(403);
|
|
202
|
+
expect((await read(adminMade.body.request_id, ntok.token!)).status).toBe(403);
|
|
203
|
+
expect((await read(adminMade.body.request_id, owner.token!, otherNet)).status).toBe(403);
|
|
204
|
+
});
|
|
205
|
+
test("cross-network, wrong expected, absent expected and command/path keys are refused", async () => {
|
|
206
|
+
expect((await enqueue("background", { network_id: otherNet })).status).toBe(403);
|
|
207
|
+
expect((await enqueue("background", { expected: { ...binding, session_id: "other_session" } })).body.error).toBe("session_control_changed");
|
|
208
|
+
expect((await api(owner.token!, root, { network_id: net, node_id: nodeId, operation: "background" })).status).toBe(400);
|
|
209
|
+
for (const key of ["url", "path", "keys", "shell"]) expect((await enqueue("state", { [key]: "unsafe" })).status).toBe(400);
|
|
210
|
+
expect((await registerBridge({ ...binding, node_id: peerId } as any)).status).toBe(400);
|
|
211
|
+
});
|
|
212
|
+
test("peer token and legacy unbound token never receive target requests", async () => {
|
|
213
|
+
const { createNetworkTokenForNode } = await import("./auth.js");
|
|
214
|
+
const legacy = createNetworkTokenForNode(owner.user.user_id, net, "sc-unbound");
|
|
215
|
+
expect(legacy.ok).toBe(true);
|
|
216
|
+
// Same-holder refreshes now bind existing rows. Construct an actual old
|
|
217
|
+
// unbound credential whose name only hints at this target instead.
|
|
218
|
+
db.run("UPDATE api_tokens SET name='node:sc-target' WHERE token_id=?1", legacy.token_id);
|
|
219
|
+
expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", legacy.token_id).bound_node_id).toBeNull();
|
|
220
|
+
expect((await registerBridge(binding, legacy.token!)).status).toBe(403);
|
|
221
|
+
expect((await registerBridge(binding, owner.token!)).status).toBe(403);
|
|
222
|
+
const made = await enqueue();
|
|
223
|
+
expect((await pull(peer.token!)).body.request).toBeUndefined();
|
|
224
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
225
|
+
expect((await api(peer.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(409);
|
|
226
|
+
});
|
|
227
|
+
test("concurrent claims yield exactly one delivery and do not replay", async () => {
|
|
228
|
+
const made = await enqueue();
|
|
229
|
+
expect((await enqueue()).status).toBe(429);
|
|
230
|
+
const claims = await Promise.all([pull(), pull(), pull()]);
|
|
231
|
+
expect(claims.filter(r => r.body.request?.request_id === made.body.request_id)).toHaveLength(1);
|
|
232
|
+
expect((await read(made.body.request_id)).body.status).toBe("in_progress");
|
|
233
|
+
expect((await pull()).body.request).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
test("expired pending request is never delivered", async () => {
|
|
236
|
+
const made = await enqueue();
|
|
237
|
+
setSystemTime(new Date(Date.now() + 16_000));
|
|
238
|
+
expect((await pull()).body.request).toBeNull();
|
|
239
|
+
expect((await read(made.body.request_id)).body.status).toBe("expired");
|
|
240
|
+
});
|
|
241
|
+
test("lost ACK becomes unknown, never success; late ACK and repeated pull cannot replay", async () => {
|
|
242
|
+
const made = await enqueue(); await pull();
|
|
243
|
+
setSystemTime(new Date(Date.now() + 16_000));
|
|
244
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_background_outcome_unknown" });
|
|
245
|
+
expect((await ack(made.body.request_id, { outcome: "request_processed", state: state() })).status).toBe(409);
|
|
246
|
+
expect((await pull()).body.request).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
test("new generation invalidates pending and cannot be overwritten by delayed old Bridge", async () => {
|
|
249
|
+
const old = { ...binding }, made = await enqueue();
|
|
250
|
+
binding = { session_id: "new_session", binding_id: `new_binding_${serial}` };
|
|
251
|
+
expect((await registerBridge()).status).toBe(200);
|
|
252
|
+
expect((await registerBridge(old)).body.error).toBe("session_control_changed");
|
|
253
|
+
expect((await pull(ntok.token!, old)).body.error).toBe("session_control_changed");
|
|
254
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "failed", error: "session_control_changed" });
|
|
255
|
+
expect((await pull()).body.request).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
test("handle change after claim is unknown and old-session ACK cannot complete", async () => {
|
|
258
|
+
const old = { ...binding }, made = await enqueue(); await pull();
|
|
259
|
+
binding = { ...binding, binding_id: `changed_${serial}` }; await registerBridge();
|
|
260
|
+
expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...old, status: "done", result: { outcome: "already_idle", state: { ...state(), ...old } } })).status).toBe(409);
|
|
261
|
+
expect((await read(made.body.request_id)).body.status).toBe("unknown");
|
|
262
|
+
});
|
|
263
|
+
test("requester revocation before pull prevents delivery and current read permission is rechecked", async () => {
|
|
264
|
+
const made = await enqueue();
|
|
265
|
+
db.run("UPDATE network_members SET role='member' WHERE network_id=?1 AND user_id=?2", [net, owner.user.user_id]);
|
|
266
|
+
db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [member.user.user_id, nodeId]);
|
|
267
|
+
try {
|
|
268
|
+
expect((await pull()).body.request).toBeNull();
|
|
269
|
+
expect((await read(made.body.request_id)).status).toBe(403);
|
|
270
|
+
expect((await read(made.body.request_id, admin.token!)).body.status).toBe("failed");
|
|
271
|
+
} finally { db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [owner.user.user_id, nodeId]); }
|
|
272
|
+
});
|
|
273
|
+
test("target token revocation is checked at pull, not only registration", async () => {
|
|
274
|
+
const made = await enqueue();
|
|
275
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", ntok.token_id);
|
|
276
|
+
try {
|
|
277
|
+
expect((await pull()).status).toBeGreaterThanOrEqual(400);
|
|
278
|
+
expect((await read(made.body.request_id)).body.status).toBe("failed");
|
|
279
|
+
} finally { db.run("UPDATE api_tokens SET revoked_at=NULL WHERE token_id=?1", ntok.token_id); }
|
|
280
|
+
});
|
|
281
|
+
test("malformed runtime results and raw exception strings never become success or leak", async () => {
|
|
282
|
+
const made = await enqueue(); await pull();
|
|
283
|
+
const reply = await ack(made.body.request_id, { outcome: "request_processed", state: { ...state(), binding_id: "wrong" } });
|
|
284
|
+
expect(reply.status).toBe(400);
|
|
285
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
|
|
286
|
+
const next = await enqueue(); await pull();
|
|
287
|
+
const secret = "http://secret-host/?token=ntok_secret";
|
|
288
|
+
expect((await api(ntok.token!, `${root}/${next.body.request_id}/ack`, { ...binding, status: "failed", error: secret })).body.error).toBe("session_control_invalid_response");
|
|
289
|
+
expect(JSON.stringify((await read(next.body.request_id)).body)).not.toContain(secret);
|
|
290
|
+
});
|
|
291
|
+
test("known runtime refusal is failed; already_idle receipt is accepted", async () => {
|
|
292
|
+
const made = await enqueue(); await pull();
|
|
293
|
+
expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "failed", error: "session_control_unsupported" })).body.status).toBe("failed");
|
|
294
|
+
const next = await enqueue(); await pull();
|
|
295
|
+
expect((await ack(next.body.request_id, { outcome: "already_idle", state: { ...state(), active: false, can_request_background: false } })).body.status).toBe("done");
|
|
296
|
+
});
|
|
297
|
+
test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
|
|
298
|
+
const made = await enqueue();
|
|
299
|
+
setSystemTime(new Date(Date.now() + 76_000));
|
|
300
|
+
expect((await enqueue()).body.error).toBe("session_control_unavailable");
|
|
301
|
+
setSystemTime();
|
|
302
|
+
server.stop(true);
|
|
303
|
+
const { bootServer } = await import("./server.js");
|
|
304
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${server.port}`;
|
|
305
|
+
expect((await read(made.body.request_id)).status).toBe(404);
|
|
306
|
+
expect((await pull()).body.error).toBe("session_control_changed");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { getUserNetworkRole, resolveToken, sessionIdleDays } from "./auth.js";
|
|
4
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
5
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
6
|
+
import { pushEvent } from "./push.js";
|
|
7
|
+
|
|
8
|
+
type Identity = { session_id: string; binding_id: string };
|
|
9
|
+
type Credential = { token_id: string; user_id: string; network_id: string | null;
|
|
10
|
+
bound_node_id: string | null; scope: string; role: string };
|
|
11
|
+
type Node = { node_id: string; network_id: string; owner_user_id: string | null; alias: string };
|
|
12
|
+
type Bridge = Identity & { tokenId: string; expires: number; retired: Set<string> };
|
|
13
|
+
type Entry = Identity & { request_id: string; operation: "state" | "background";
|
|
14
|
+
networkId: string; nodeId: string; tokenId: string; requester: string; expires_at: number;
|
|
15
|
+
status: "pending" | "in_progress" | "done" | "failed" | "unknown" | "expired";
|
|
16
|
+
result?: unknown; error?: string };
|
|
17
|
+
const TTL = 15_000, LEASE = 75_000, CAP = 1024, HISTORY = 64;
|
|
18
|
+
const ROOT = "/api/node-session-control";
|
|
19
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
20
|
+
const ERRORS = new Set(["unavailable", "changed", "unsupported", "invalid_response", "request_invalid",
|
|
21
|
+
"request_expired", "request_limit"].map(s => `session_control_${s}`).concat("session_background_outcome_unknown"));
|
|
22
|
+
const keys = (o: Record<string, unknown>, allowed: string[]) => Object.keys(o).every(k => allowed.includes(k));
|
|
23
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
24
|
+
const id = (v: unknown): v is string => typeof v === "string" && ID.test(v);
|
|
25
|
+
const identity = (v: unknown): v is Identity => object(v) && keys(v, ["session_id", "binding_id"])
|
|
26
|
+
&& id(v.session_id) && id(v.binding_id);
|
|
27
|
+
const same = (a: Identity, b: Identity) => a.session_id === b.session_id && a.binding_id === b.binding_id;
|
|
28
|
+
const generation = (token: string, v: Identity) => JSON.stringify([token, v.session_id, v.binding_id]);
|
|
29
|
+
const fail = (error: string, status = 409) => Response.json({ ok: false, error }, { status });
|
|
30
|
+
const ok = (value: object) => Response.json({ ok: true, ...value });
|
|
31
|
+
|
|
32
|
+
// Recheck stored identities without storing plaintext bearer tokens. The SQL
|
|
33
|
+
// mirrors resolveToken's expiry/revoke/idle rules; membership is checked below.
|
|
34
|
+
function credential(tokenId: string): Credential | null {
|
|
35
|
+
const days = sessionIdleDays();
|
|
36
|
+
return db.get<Credential>(`SELECT t.token_id,t.user_id,t.network_id,t.bound_node_id,t.scope,u.role
|
|
37
|
+
FROM api_tokens t JOIN users u ON u.user_id=t.user_id WHERE t.token_id=?1
|
|
38
|
+
AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>datetime('now'))
|
|
39
|
+
AND NOT (t.scope='user' AND t.network_id IS NULL AND ?2>0
|
|
40
|
+
AND COALESCE(t.last_used_at,t.created_at)<datetime('now',?3))`,
|
|
41
|
+
tokenId, days, `-${Math.round(days * 86400)} seconds`) ?? null;
|
|
42
|
+
}
|
|
43
|
+
function node(nodeId: string): Node | null {
|
|
44
|
+
return db.get<Node>("SELECT node_id,network_id,owner_user_id,alias FROM nodes WHERE node_id=?1", nodeId) ?? null;
|
|
45
|
+
}
|
|
46
|
+
function isHumanCredential(c: Credential | null): c is Credential {
|
|
47
|
+
return !!c && c.network_id === null && c.bound_node_id === null
|
|
48
|
+
&& (c.scope === "user" || c.scope === "full");
|
|
49
|
+
}
|
|
50
|
+
function human(tokenId: string, n: Node): boolean {
|
|
51
|
+
const c = credential(tokenId);
|
|
52
|
+
if (!isHumanCredential(c) || !n.network_id) return false;
|
|
53
|
+
const role = getUserNetworkRole(c.user_id, n.network_id);
|
|
54
|
+
return !!role && role !== "viewer" && !isAgentRestricted(c.user_id, n.network_id)
|
|
55
|
+
&& (c.user_id === n.owner_user_id || role === "owner" || role === "admin" || c.role === "admin");
|
|
56
|
+
}
|
|
57
|
+
function bound(tokenId: string, n?: Node | null): Node | null {
|
|
58
|
+
const c = credential(tokenId);
|
|
59
|
+
if (!c?.bound_node_id || !c.network_id || c.scope !== "network") return null;
|
|
60
|
+
const target = n ?? node(c.bound_node_id);
|
|
61
|
+
if (!target || c.bound_node_id !== target.node_id || c.network_id !== target.network_id
|
|
62
|
+
|| !getUserNetworkRole(c.user_id, c.network_id) || isAgentRestricted(c.user_id, c.network_id)) return null;
|
|
63
|
+
const caller = resolveNodeCaller(tokenId);
|
|
64
|
+
return caller.ok && caller.kind === "node" && caller.nodeId === target.node_id
|
|
65
|
+
&& caller.networkId === target.network_id ? target : null;
|
|
66
|
+
}
|
|
67
|
+
async function body(req: Request): Promise<Record<string, unknown> | null> {
|
|
68
|
+
const reader = req.body?.getReader();
|
|
69
|
+
if (!reader) return null;
|
|
70
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const part = await reader.read(); if (part.done) break;
|
|
74
|
+
size += part.value.byteLength;
|
|
75
|
+
if (size > 8192) { await reader.cancel(); return null; }
|
|
76
|
+
chunks.push(part.value);
|
|
77
|
+
}
|
|
78
|
+
const v: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
79
|
+
return object(v) ? v : null;
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
function state(value: unknown, e: Entry): boolean {
|
|
83
|
+
return object(value) && keys(value, ["session_id", "binding_id", "supported", "active", "can_request_background", "observed_at"])
|
|
84
|
+
&& same(value as Identity, e) && typeof value.supported === "boolean" && typeof value.active === "boolean"
|
|
85
|
+
&& typeof value.can_request_background === "boolean" && typeof value.observed_at === "number"
|
|
86
|
+
&& Number.isFinite(value.observed_at) && value.observed_at > 0
|
|
87
|
+
&& (!value.can_request_background || (value.supported && value.active));
|
|
88
|
+
}
|
|
89
|
+
function validResult(v: unknown, e: Entry): boolean {
|
|
90
|
+
if (e.operation === "state") return state(v, e);
|
|
91
|
+
return object(v) && keys(v, ["outcome", "state"])
|
|
92
|
+
&& (v.outcome === "request_processed" || v.outcome === "already_idle") && state(v.state, e)
|
|
93
|
+
&& (v.outcome !== "already_idle" || (object(v.state)
|
|
94
|
+
&& v.state.active === false && v.state.can_request_background === false));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One finite, process-local relay per Hub boot. Restart loses requests (404),
|
|
98
|
+
* never replays them. Multi-worker deployments require process affinity.
|
|
99
|
+
* A claim authorizes one delivery, not proof that any tool moved to background.
|
|
100
|
+
*/
|
|
101
|
+
export function createNodeSessionControlRelay() {
|
|
102
|
+
const bridges = new Map<string, Bridge>();
|
|
103
|
+
const entries = new Map<string, Entry>();
|
|
104
|
+
const key = (n: Node) => JSON.stringify([n.network_id, n.node_id]);
|
|
105
|
+
function close(e: Entry, error: string) {
|
|
106
|
+
e.status = e.status === "in_progress" ? "unknown" : "failed";
|
|
107
|
+
e.error = e.status === "unknown" ? "session_background_outcome_unknown" : error;
|
|
108
|
+
}
|
|
109
|
+
function refresh(e: Entry, now: number) {
|
|
110
|
+
if (e.status !== "pending" && e.status !== "in_progress") return;
|
|
111
|
+
if (now >= e.expires_at) {
|
|
112
|
+
e.status = e.status === "in_progress" ? "unknown" : "expired";
|
|
113
|
+
e.error = e.status === "unknown" ? "session_background_outcome_unknown" : "session_control_request_expired";
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const n = node(e.nodeId), b = n && bridges.get(key(n));
|
|
117
|
+
if (!n || n.network_id !== e.networkId || !human(e.requester, n) || !bound(e.tokenId, n)) {
|
|
118
|
+
close(e, "session_control_unavailable");
|
|
119
|
+
} else if (!b || b.expires <= now || b.tokenId !== e.tokenId || !same(b, e)) {
|
|
120
|
+
close(e, "session_control_changed");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function sweep(now: number) {
|
|
124
|
+
for (const [k, e] of entries) {
|
|
125
|
+
refresh(e, now);
|
|
126
|
+
if (now > e.expires_at + 60_000) entries.delete(k);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return async function handle(req: Request, url: URL): Promise<Response | null> {
|
|
130
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`)) return null;
|
|
131
|
+
try {
|
|
132
|
+
// Header-only, persisted credential authority; no master/dev-open fallback.
|
|
133
|
+
const raw = req.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? "";
|
|
134
|
+
const auth = resolveToken(raw);
|
|
135
|
+
if (!auth?.tokenId) return fail("session_control_unavailable", 401);
|
|
136
|
+
const tokenId = auth.tokenId;
|
|
137
|
+
const c = credential(tokenId);
|
|
138
|
+
if (!c) return fail("session_control_unavailable", 401);
|
|
139
|
+
const isHuman = isHumanCredential(c);
|
|
140
|
+
const isNode = c.scope === "network" && !!c.network_id;
|
|
141
|
+
const suffix = url.pathname.slice(ROOT.length);
|
|
142
|
+
if (req.method === "GET" && /^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}$/.test(suffix)) {
|
|
143
|
+
const e = entries.get(suffix.slice(1));
|
|
144
|
+
// Unknown/restarted Hub and unauthorized reads reveal no request data.
|
|
145
|
+
if (!isHuman) return fail("session_control_unavailable", 403);
|
|
146
|
+
if (!e) return fail("session_control_unavailable", 404);
|
|
147
|
+
const n = node(e.nodeId);
|
|
148
|
+
if (!n || url.searchParams.get("network_id") !== e.networkId || n.network_id !== e.networkId || !human(tokenId, n)) {
|
|
149
|
+
return fail("session_control_unavailable", 403);
|
|
150
|
+
}
|
|
151
|
+
refresh(e, Date.now());
|
|
152
|
+
return ok({ request_id: e.request_id, status: e.status,
|
|
153
|
+
...(e.result === undefined ? {} : { result: e.result }), ...(e.error ? { error: e.error } : {}) });
|
|
154
|
+
}
|
|
155
|
+
if (req.method !== "POST") return fail("session_control_request_invalid", 405);
|
|
156
|
+
const v = await body(req);
|
|
157
|
+
if (!v) return fail("session_control_request_invalid", 400);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (suffix === "/bridge") {
|
|
160
|
+
if (!isNode) return fail("session_control_unavailable", 403);
|
|
161
|
+
if (!keys(v, ["session_id", "binding_id", "expected"]) || !id(v.session_id) || !id(v.binding_id)
|
|
162
|
+
|| !object(v.expected) || !keys(v.expected, ["node_id", "network_id"])
|
|
163
|
+
|| !id(v.expected.node_id) || !id(v.expected.network_id)) return fail("session_control_request_invalid", 400);
|
|
164
|
+
// Body reading is complete. This synchronous section rechecks authority
|
|
165
|
+
// and expected identity BEFORE sweep, retirement, or registration writes.
|
|
166
|
+
// expected is an assertion, never a source for binding or proof fields.
|
|
167
|
+
const c = credential(tokenId), n = bound(tokenId);
|
|
168
|
+
if (!c || !n) return fail("session_control_unavailable", 403);
|
|
169
|
+
if (c.bound_node_id !== n.node_id || c.network_id !== n.network_id
|
|
170
|
+
|| v.expected.node_id !== c.bound_node_id || v.expected.network_id !== c.network_id) {
|
|
171
|
+
return fail("session_control_changed");
|
|
172
|
+
}
|
|
173
|
+
const registration = { session_id: v.session_id, binding_id: v.binding_id };
|
|
174
|
+
const k = key(n), b = bridges.get(k), g = generation(tokenId, registration);
|
|
175
|
+
if (b?.retired.has(g)) return fail("session_control_changed");
|
|
176
|
+
const replacing = b && (b.tokenId !== tokenId || !same(b, registration));
|
|
177
|
+
if ((replacing && b.retired.size >= HISTORY) || (!b && bridges.size >= CAP)) {
|
|
178
|
+
return fail("session_control_request_limit", 429);
|
|
179
|
+
}
|
|
180
|
+
sweep(now);
|
|
181
|
+
if (replacing) {
|
|
182
|
+
b.retired.add(generation(b.tokenId, b));
|
|
183
|
+
for (const e of entries.values()) if (e.nodeId === n.node_id && e.networkId === n.network_id
|
|
184
|
+
&& (e.status === "pending" || e.status === "in_progress")) close(e, "session_control_changed");
|
|
185
|
+
}
|
|
186
|
+
bridges.set(k, { ...registration, tokenId, expires: now + LEASE, retired: b?.retired ?? new Set() });
|
|
187
|
+
return ok({ expires_at: now + LEASE,
|
|
188
|
+
identity: { node_id: c.bound_node_id, network_id: c.network_id, token_id: c.token_id } });
|
|
189
|
+
}
|
|
190
|
+
sweep(now);
|
|
191
|
+
if (!suffix) {
|
|
192
|
+
if (!isHuman) return fail("session_control_unavailable", 403);
|
|
193
|
+
if (!keys(v, ["network_id", "node_id", "operation", "expected"]) || !id(v.network_id) || !id(v.node_id)
|
|
194
|
+
|| (v.operation !== "state" && v.operation !== "background")
|
|
195
|
+
|| (v.expected !== undefined && !identity(v.expected)) || (v.operation === "background" && !identity(v.expected))) {
|
|
196
|
+
return fail("session_control_request_invalid", 400);
|
|
197
|
+
}
|
|
198
|
+
const n = node(v.node_id);
|
|
199
|
+
if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
|
|
200
|
+
const b = bridges.get(key(n));
|
|
201
|
+
if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_unavailable");
|
|
202
|
+
if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
|
|
203
|
+
if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
|
|
204
|
+
&& e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
|
|
205
|
+
return fail("session_control_request_limit", 429);
|
|
206
|
+
}
|
|
207
|
+
const request_id = `${v.operation === "background" ? "bg" : "sc"}_${now}_${randomBytes(18).toString("base64url")}`;
|
|
208
|
+
entries.set(request_id, { session_id: b.session_id, binding_id: b.binding_id, request_id,
|
|
209
|
+
operation: v.operation, networkId: n.network_id, nodeId: n.node_id, tokenId: b.tokenId,
|
|
210
|
+
requester: tokenId, expires_at: now + TTL, status: "pending" });
|
|
211
|
+
pushEvent(n.alias, { type: "session_control", request_id }, n.network_id);
|
|
212
|
+
return ok({ request_id, status: "pending", expires_at: now + TTL });
|
|
213
|
+
}
|
|
214
|
+
if (!isNode) return fail("session_control_unavailable", 403);
|
|
215
|
+
const n = bound(tokenId);
|
|
216
|
+
if (!n) return fail("session_control_unavailable", 403);
|
|
217
|
+
const k = key(n), b = bridges.get(k);
|
|
218
|
+
if (!b || b.expires <= now || b.tokenId !== tokenId) return fail("session_control_changed");
|
|
219
|
+
if (suffix === "/pull") {
|
|
220
|
+
if (!identity(v)) return fail("session_control_request_invalid", 400);
|
|
221
|
+
if (!same(b, v)) return fail("session_control_changed");
|
|
222
|
+
const e = [...entries.values()].find(e => e.nodeId === n.node_id && e.networkId === n.network_id && e.status === "pending");
|
|
223
|
+
if (!e) return ok({ request: null });
|
|
224
|
+
refresh(e, now);
|
|
225
|
+
if (e.status !== "pending") return ok({ request: null });
|
|
226
|
+
// No await between authority recheck, claim, and response. Never requeue.
|
|
227
|
+
e.status = "in_progress";
|
|
228
|
+
return ok({ request: { request_id: e.request_id, operation: e.operation,
|
|
229
|
+
session_id: e.session_id, binding_id: e.binding_id, expires_at: e.expires_at } });
|
|
230
|
+
}
|
|
231
|
+
if (/^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}\/ack$/.test(suffix)) {
|
|
232
|
+
if (!keys(v, ["session_id", "binding_id", "status", "result", "error"]) || !id(v.session_id) || !id(v.binding_id)
|
|
233
|
+
|| (v.status !== "done" && v.status !== "failed")) return fail("session_control_request_invalid", 400);
|
|
234
|
+
const e = entries.get(suffix.split("/")[1]);
|
|
235
|
+
if (!e) return fail("session_control_unavailable", 404);
|
|
236
|
+
if (e.nodeId !== n.node_id || e.networkId !== n.network_id || e.tokenId !== tokenId || !same(e, v as Identity)
|
|
237
|
+
|| !same(b, e)) return fail("session_control_changed");
|
|
238
|
+
refresh(e, now);
|
|
239
|
+
if (e.status !== "in_progress") return fail("session_control_request_expired");
|
|
240
|
+
if (v.status === "done" && validResult(v.result, e) && v.error === undefined) {
|
|
241
|
+
e.status = "done"; e.result = v.result;
|
|
242
|
+
} else if (v.status === "failed" && typeof v.error === "string" && ERRORS.has(v.error) && v.result === undefined) {
|
|
243
|
+
e.status = "failed"; e.error = v.error;
|
|
244
|
+
} else {
|
|
245
|
+
// Invalid/untrusted runtime replies never become success or leak raw errors.
|
|
246
|
+
e.status = "unknown"; e.error = "session_control_invalid_response";
|
|
247
|
+
return fail(e.error, 400);
|
|
248
|
+
}
|
|
249
|
+
return ok({ request_id: e.request_id, status: e.status });
|
|
250
|
+
}
|
|
251
|
+
return fail("session_control_request_invalid", 404);
|
|
252
|
+
} catch { return fail("session_control_unavailable", 503); }
|
|
253
|
+
};
|
|
254
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -92,12 +92,14 @@ import { requestNodeSecrets, getScopedSecrets } from "./scoped-secrets.js";
|
|
|
92
92
|
import { readEnvTransportLog } from "./env-transport-gate.js";
|
|
93
93
|
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
|
|
94
94
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
95
|
+
import { createNodeSessionControlRelay } from "./node-session-control.js";
|
|
95
96
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
96
97
|
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
97
98
|
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
98
99
|
import { createProductionSideThreadTransport } from "./side-thread-production.js";
|
|
99
100
|
import { parseHubTimestamp } from "./hub-timestamp";
|
|
100
101
|
import { logTaskRejection } from "./task-rejection-log.js";
|
|
102
|
+
import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
|
|
101
103
|
|
|
102
104
|
const PORT = resolvePort(process.env.PORT);
|
|
103
105
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -1027,6 +1029,7 @@ function patrolDeliveredStaleTasks(): void {
|
|
|
1027
1029
|
// Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||`
|
|
1028
1030
|
// would swallow a legitimate `0`. Same for hostname.
|
|
1029
1031
|
export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
|
|
1032
|
+
const nodeSessionControl = createNodeSessionControlRelay();
|
|
1030
1033
|
return Bun.serve({
|
|
1031
1034
|
port: opts?.port ?? PORT,
|
|
1032
1035
|
hostname: opts?.hostname ?? HOST,
|
|
@@ -1485,10 +1488,148 @@ return Bun.serve({
|
|
|
1485
1488
|
const avatar = validateAvatarUrl((body as { avatar_url: unknown }).avatar_url);
|
|
1486
1489
|
if (!avatar.ok) return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: avatar.reason }, { status: 400 }));
|
|
1487
1490
|
// No supplied target ID/alias; user identity comes exclusively from auth.
|
|
1488
|
-
|
|
1491
|
+
// 清空或换成另一张(池/外链)时,旧托管行必须同一事务撤销:否则「清空后旧 URL 仍可读」。
|
|
1492
|
+
db.transaction(() => {
|
|
1493
|
+
db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [avatar.value, resolved.user.user_id]);
|
|
1494
|
+
const keepsUploaded = typeof avatar.value === "string" && /^\/api\/users\/[^/]+\/avatar\/[A-Za-z0-9_-]{1,128}\.png$/.test(avatar.value);
|
|
1495
|
+
if (!keepsUploaded) db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [resolved.user.user_id]);
|
|
1496
|
+
});
|
|
1489
1497
|
return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: avatar.value }));
|
|
1490
1498
|
}
|
|
1491
1499
|
|
|
1500
|
+
// ── 账号头像上传(Hub 托管,非普通附件)──
|
|
1501
|
+
//
|
|
1502
|
+
// 契约(通信Mac马 交付单):用户令牌 + multipart 仅一个 `file` 字段;PNG、
|
|
1503
|
+
// 最长边 ≤512、≤8 MiB;服务端独立复核后原子覆盖本人头像;URL 由 Hub 发。
|
|
1504
|
+
// 读取走 GET /api/users/:id/avatar/:version.png,按**人类资料可见性**判读
|
|
1505
|
+
// (本人或共享网络的合法成员),不借用 /api/files 的附件 ACL。
|
|
1506
|
+
if (url.pathname === "/api/auth/me/avatar/upload" && req.method === "POST") {
|
|
1507
|
+
// pre-drain 拒绝统一关闭连接:keepalive 上残留的 multipart 未读字节会把
|
|
1508
|
+
// 下一个请求读成垃圾(与 /api/upload 的 earlyReject 同一理由)。
|
|
1509
|
+
const earlyReject = (res: Response): Response => {
|
|
1510
|
+
const wrapped = withCors(req, res);
|
|
1511
|
+
wrapped.headers.set("Connection", "close");
|
|
1512
|
+
return wrapped;
|
|
1513
|
+
};
|
|
1514
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1515
|
+
if (!token) return earlyReject(Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1516
|
+
const resolved = resolveToken(token);
|
|
1517
|
+
if (!resolved) return earlyReject(Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1518
|
+
// 节点令牌代表机器,不是这个人:头像只能由本人用用户令牌写。
|
|
1519
|
+
if (isNodeCredential(resolved)) return earlyReject(userTokenRequired(req));
|
|
1520
|
+
|
|
1521
|
+
// 解析 multipart **之前**先限流:否则一个被限流的调用方仍能让 Hub 读满 8 MiB。
|
|
1522
|
+
const authCtx = resolveRequestAuth(req);
|
|
1523
|
+
const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
|
|
1524
|
+
const rate = sharedUploadRateLimiter.check(rateKey);
|
|
1525
|
+
if (!rate.allowed) {
|
|
1526
|
+
const headers: Record<string, string> = {
|
|
1527
|
+
"X-RateLimit-Limit": String(60),
|
|
1528
|
+
"X-RateLimit-Remaining": "0",
|
|
1529
|
+
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1530
|
+
};
|
|
1531
|
+
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1532
|
+
return earlyReject(new Response(
|
|
1533
|
+
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1534
|
+
{ status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } },
|
|
1535
|
+
));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (!isMultipart(req.headers.get("content-type"))) {
|
|
1539
|
+
return earlyReject(Response.json({ ok: false, error: "multipart_required" }, { status: 415 }));
|
|
1540
|
+
}
|
|
1541
|
+
const declared = Number(req.headers.get("content-length") ?? NaN);
|
|
1542
|
+
// 多留 1 MiB 给 multipart 信封;真正的二进制上限在解析后按 MAX_AVATAR_BYTES 再判一次。
|
|
1543
|
+
if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES + 1024 * 1024) {
|
|
1544
|
+
return earlyReject(Response.json({ ok: false, error: "too_large" }, { status: 413 }));
|
|
1545
|
+
}
|
|
1546
|
+
let form: FormData;
|
|
1547
|
+
try { form = await req.formData(); } catch {
|
|
1548
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_multipart" }, { status: 400 }));
|
|
1549
|
+
}
|
|
1550
|
+
const fields = [...form.keys()];
|
|
1551
|
+
const file = form.get(AVATAR_FIELD);
|
|
1552
|
+
if (fields.length !== 1 || fields[0] !== AVATAR_FIELD || !(file instanceof File)) {
|
|
1553
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected exactly one `file` part" }, { status: 400 }));
|
|
1554
|
+
}
|
|
1555
|
+
if (file.size > MAX_AVATAR_BYTES) return withCors(req, Response.json({ ok: false, error: "too_large" }, { status: 413 }));
|
|
1556
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1557
|
+
const check = inspectAvatarImage(bytes);
|
|
1558
|
+
if (!check.ok) return withCors(req, Response.json({ ok: false, error: "invalid_image", reason: check.reason }, { status: 400 }));
|
|
1559
|
+
const version = crypto.randomUUID().replace(/-/g, "");
|
|
1560
|
+
const url = avatarUrl(resolved.user.user_id, version);
|
|
1561
|
+
// 头像表与用户资料必须在同一事务里落地:否则会出现「上传成功但资料页还是旧头像」,
|
|
1562
|
+
// 或反过来点了清空却仍能读到旧字节。
|
|
1563
|
+
db.transaction(() => {
|
|
1564
|
+
db.run(
|
|
1565
|
+
`INSERT INTO user_avatar_uploads (user_id, version, png, bytes, width, height, updated_at)
|
|
1566
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))
|
|
1567
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
1568
|
+
version = excluded.version, png = excluded.png, bytes = excluded.bytes,
|
|
1569
|
+
width = excluded.width, height = excluded.height, updated_at = excluded.updated_at`,
|
|
1570
|
+
[resolved.user.user_id, version, bytes, bytes.byteLength, check.width, check.height],
|
|
1571
|
+
);
|
|
1572
|
+
db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [url, resolved.user.user_id]);
|
|
1573
|
+
});
|
|
1574
|
+
// 回读用户资料行,确认读取面能看到这张新头像(而不是只信内存里的变量)。
|
|
1575
|
+
const persisted = db.get<{ avatar_url: string | null }>("SELECT avatar_url FROM users WHERE user_id = ?1", [resolved.user.user_id]);
|
|
1576
|
+
if (persisted?.avatar_url !== url) {
|
|
1577
|
+
return withCors(req, Response.json({ ok: false, error: "avatar_persist_failed" }, { status: 500 }));
|
|
1578
|
+
}
|
|
1579
|
+
return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: url }));
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// GET /api/users/:user_id/avatar/:version.png —— Bearer 用户令牌鉴权。
|
|
1583
|
+
// 可见性 = 本人,或与调用者共享至少一个网络的合法人类成员;节点令牌不是「人」。
|
|
1584
|
+
// 不可见与不存在都回 404:不让人用状态码探测别人的账号是否存在。
|
|
1585
|
+
const userAvatarMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/avatar\/([^/]+)\.png$/);
|
|
1586
|
+
if (userAvatarMatch && req.method === "GET") {
|
|
1587
|
+
const [, rawUserId, rawVersion] = userAvatarMatch;
|
|
1588
|
+
let targetUserId: string;
|
|
1589
|
+
try { targetUserId = decodeURIComponent(rawUserId); } catch {
|
|
1590
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1591
|
+
}
|
|
1592
|
+
if (!isValidAvatarVersion(rawVersion)) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1593
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1594
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1595
|
+
const resolved = resolveToken(token);
|
|
1596
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1597
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1598
|
+
const callerId = resolved.user.user_id;
|
|
1599
|
+
if (callerId !== targetUserId) {
|
|
1600
|
+
// live membership:网络必须仍然存在(删除网络只留下 network_members 残行时不算共享)。
|
|
1601
|
+
const shared = db.get<{ ok: number }>(
|
|
1602
|
+
`SELECT 1 AS ok FROM network_members me
|
|
1603
|
+
JOIN networks n ON n.network_id = me.network_id
|
|
1604
|
+
JOIN network_members them ON them.network_id = me.network_id
|
|
1605
|
+
WHERE me.user_id = ?1 AND them.user_id = ?2 LIMIT 1`,
|
|
1606
|
+
[callerId, targetUserId],
|
|
1607
|
+
);
|
|
1608
|
+
if (!shared) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1609
|
+
}
|
|
1610
|
+
// 只服务「当前」头像:清空/替换后旧 version 立即失效,即使行还没被删。
|
|
1611
|
+
const current = db.get<{ avatar_url: string | null }>(
|
|
1612
|
+
"SELECT avatar_url FROM users WHERE user_id = ?1", [targetUserId],
|
|
1613
|
+
);
|
|
1614
|
+
if (!current || current.avatar_url !== avatarUrl(targetUserId, rawVersion)) {
|
|
1615
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1616
|
+
}
|
|
1617
|
+
const row = db.get<{ png: Uint8Array }>(
|
|
1618
|
+
"SELECT png FROM user_avatar_uploads WHERE user_id = ?1 AND version = ?2",
|
|
1619
|
+
[targetUserId, rawVersion],
|
|
1620
|
+
);
|
|
1621
|
+
if (!row) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1622
|
+
return withCors(req, new Response(Buffer.from(row.png), {
|
|
1623
|
+
status: 200,
|
|
1624
|
+
headers: {
|
|
1625
|
+
"Content-Type": "image/png",
|
|
1626
|
+
// 头像随账号更新,不能让中间层缓存住旧的那张;也不做重定向。
|
|
1627
|
+
"Cache-Control": "private, no-store",
|
|
1628
|
+
"X-Content-Type-Options": "nosniff",
|
|
1629
|
+
},
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1492
1633
|
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
1493
1634
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1494
1635
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
@@ -2655,6 +2796,9 @@ return Bun.serve({
|
|
|
2655
2796
|
});
|
|
2656
2797
|
if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
|
|
2657
2798
|
|
|
2799
|
+
const sessionControlResponse = await nodeSessionControl(req, url);
|
|
2800
|
+
if (sessionControlResponse) return withCors(req, sessionControlResponse);
|
|
2801
|
+
|
|
2658
2802
|
// Hub-owned scheduled tasks. Dashboard and mobile are management
|
|
2659
2803
|
// clients only; each occurrence is dispatched as an ordinary task by the
|
|
2660
2804
|
// scheduler started in startHub(). Node tokens are rejected in the
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// #725-adjacent? No: Hub 专用账号头像上传(通信Mac马 交付单)。
|
|
2
|
+
//
|
|
3
|
+
// 头像不是普通附件:它必须 PNG、边长 ≤512、≤8 MiB,且只能由本人用自己的
|
|
4
|
+
// **用户令牌**写入;读取按「人类资料可见性」(本人或共享网络的合法成员)判定,
|
|
5
|
+
// 不借用 /api/files 的附件 ACL(那是按网络/会话/DM 作用域判的,语义不同)。
|
|
6
|
+
//
|
|
7
|
+
// 这个模块只做纯校验,便于单测与复用;HTTP 与落库在 server.ts。
|
|
8
|
+
import { inflateSync } from "node:zlib";
|
|
9
|
+
|
|
10
|
+
// 校验强度:结构(签名/chunk/CRC)+ 真实解码(IDAT 解压且扫描行长度与 IHDR 相符)。
|
|
11
|
+
|
|
12
|
+
export const MAX_AVATAR_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
/** 客户端会把图重编码到最长边 512;服务端独立复核这条上限。 */
|
|
14
|
+
export const MAX_AVATAR_EDGE = 512;
|
|
15
|
+
/** version 只允许 URL 安全字符,长度有界(避免拼路径时引入 `.`、`/`)。 */
|
|
16
|
+
export const AVATAR_VERSION_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
export const AVATAR_FIELD = "file";
|
|
18
|
+
|
|
19
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
20
|
+
|
|
21
|
+
export type AvatarImageCheck =
|
|
22
|
+
| { ok: true; width: number; height: number }
|
|
23
|
+
| { ok: false; reason: "empty" | "too_large" | "not_png" | "truncated" | "bad_dimensions" | "too_big" | "bad_crc" | "bad_image" | "interlaced_unsupported" };
|
|
24
|
+
|
|
25
|
+
const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/** 标准 CRC-32(PNG 每个 chunk 的尾部)。表只建一次。 */
|
|
29
|
+
const CRC_TABLE = (() => {
|
|
30
|
+
const table = new Uint32Array(256);
|
|
31
|
+
for (let n = 0; n < 256; n++) {
|
|
32
|
+
let c = n;
|
|
33
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
34
|
+
table[n] = c >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
function crc32(bytes: Uint8Array): number {
|
|
40
|
+
let c = 0xffffffff;
|
|
41
|
+
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
42
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function be32(bytes: Uint8Array, offset: number): number {
|
|
46
|
+
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PNG 结构校验:签名 + 逐个 chunk 的 CRC + 必须有 IDAT 且以 IEND 结尾。
|
|
51
|
+
*
|
|
52
|
+
* 只信任「结构完整」而不是「声明了什么」:截断的 33/40 字节、零填充的伪图
|
|
53
|
+
* (IHDR 对但 CRC 与 IDAT 缺失)都会被拒;合法用户因此不能把已有头像
|
|
54
|
+
* 覆盖成一张显示不出来的图。
|
|
55
|
+
*/
|
|
56
|
+
export function inspectAvatarImage(input: Uint8Array): AvatarImageCheck {
|
|
57
|
+
if (input.byteLength === 0) return { ok: false, reason: "empty" };
|
|
58
|
+
if (input.byteLength > MAX_AVATAR_BYTES) return { ok: false, reason: "too_large" };
|
|
59
|
+
for (let i = 0; i < PNG_SIGNATURE.length && i < input.byteLength; i++) {
|
|
60
|
+
if (input[i] !== PNG_SIGNATURE[i]) return { ok: false, reason: "not_png" };
|
|
61
|
+
}
|
|
62
|
+
if (input.byteLength < 45) return { ok: false, reason: "truncated" }; // 签名 + IHDR(25) + 最小 IEND(12)
|
|
63
|
+
|
|
64
|
+
let pos = 8;
|
|
65
|
+
let width = 0;
|
|
66
|
+
let height = 0;
|
|
67
|
+
let sawIHDR = false;
|
|
68
|
+
let idatChunks = 0;
|
|
69
|
+
let sawIEND = false;
|
|
70
|
+
let sawPLTE = false;
|
|
71
|
+
let bitDepth = 0;
|
|
72
|
+
let colorType = 0;
|
|
73
|
+
const idat: Uint8Array[] = [];
|
|
74
|
+
|
|
75
|
+
while (pos + 12 <= input.byteLength) {
|
|
76
|
+
const length = be32(input, pos);
|
|
77
|
+
const type = String.fromCharCode(input[pos + 4], input[pos + 5], input[pos + 6], input[pos + 7]);
|
|
78
|
+
const dataStart = pos + 8;
|
|
79
|
+
const dataEnd = dataStart + length;
|
|
80
|
+
const crcEnd = dataEnd + 4;
|
|
81
|
+
if (crcEnd > input.byteLength) return { ok: false, reason: "truncated" };
|
|
82
|
+
if (!sawIHDR && type !== "IHDR") return { ok: false, reason: "not_png" };
|
|
83
|
+
if ((crc32(input.subarray(pos + 4, dataEnd)) >>> 0) !== be32(input, dataEnd)) {
|
|
84
|
+
return { ok: false, reason: "bad_crc" };
|
|
85
|
+
}
|
|
86
|
+
if (type === "IHDR") {
|
|
87
|
+
if (sawIHDR || length !== 13) return { ok: false, reason: "not_png" };
|
|
88
|
+
width = be32(input, dataStart);
|
|
89
|
+
height = be32(input, dataStart + 4);
|
|
90
|
+
bitDepth = input[dataStart + 8];
|
|
91
|
+
colorType = input[dataStart + 9];
|
|
92
|
+
const compression = input[dataStart + 10];
|
|
93
|
+
const filter = input[dataStart + 11];
|
|
94
|
+
const interlace = input[dataStart + 12];
|
|
95
|
+
// 只接受规范的 IHDR 取值组合;非法组合的图多数解码器也会拒绝。
|
|
96
|
+
const legalDepth: Record<number, number[]> = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
|
|
97
|
+
if (!legalDepth[colorType]?.includes(bitDepth)) return { ok: false, reason: "not_png" };
|
|
98
|
+
if (compression !== 0 || filter !== 0) return { ok: false, reason: "not_png" };
|
|
99
|
+
// 本实现只校验逐行(interlace=0)扫描布局:Adam7(=1) 的布局不同,其余取值(2..255)
|
|
100
|
+
// 在 PNG 规范里根本不合法。一律明确拒收,而不是错算长度后放行。
|
|
101
|
+
if (interlace !== 0) return { ok: false, reason: "interlaced_unsupported" };
|
|
102
|
+
if (width === 0 || height === 0) return { ok: false, reason: "bad_dimensions" };
|
|
103
|
+
if (width > MAX_AVATAR_EDGE || height > MAX_AVATAR_EDGE) return { ok: false, reason: "too_big" };
|
|
104
|
+
sawIHDR = true;
|
|
105
|
+
} else if (type === "PLTE") {
|
|
106
|
+
if (length === 0 || length % 3 !== 0) return { ok: false, reason: "not_png" };
|
|
107
|
+
sawPLTE = true;
|
|
108
|
+
} else if (type === "IDAT") {
|
|
109
|
+
idatChunks++;
|
|
110
|
+
idat.push(input.subarray(dataStart, dataEnd));
|
|
111
|
+
} else if (type === "IEND") {
|
|
112
|
+
if (length !== 0) return { ok: false, reason: "not_png" };
|
|
113
|
+
if (crcEnd !== input.byteLength) return { ok: false, reason: "not_png" };
|
|
114
|
+
sawIEND = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
pos = crcEnd;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!sawIHDR || !sawIEND) return { ok: false, reason: "truncated" };
|
|
121
|
+
if (idatChunks === 0) return { ok: false, reason: "not_png" };
|
|
122
|
+
// 调色板图必须带 PLTE,否则解码器无从取色。
|
|
123
|
+
if (colorType === 3 && !sawPLTE) return { ok: false, reason: "not_png" };
|
|
124
|
+
// CRC 只证明「容器没坏」,不证明 IDAT 里是图像数据:真解一次 zlib,并核对
|
|
125
|
+
// 解压后长度正好等于 IHDR 声明的扫描行总长(每行 1 字节 filter + 像素字节)。
|
|
126
|
+
const bytesPerRow = Math.ceil((width * (CHANNELS[colorType] ?? 0) * bitDepth) / 8);
|
|
127
|
+
const expected = height * (1 + bytesPerRow);
|
|
128
|
+
let raw: Uint8Array;
|
|
129
|
+
try {
|
|
130
|
+
const merged = Buffer.concat(idat.map(part => Buffer.from(part)));
|
|
131
|
+
raw = new Uint8Array(inflateSync(merged, { maxOutputLength: expected + 1024 }));
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, reason: "bad_image" };
|
|
134
|
+
}
|
|
135
|
+
if (raw.byteLength !== expected) return { ok: false, reason: "bad_image" };
|
|
136
|
+
for (let row = 0; row < height; row++) {
|
|
137
|
+
if (raw[row * (1 + bytesPerRow)] > 4) return { ok: false, reason: "bad_image" }; // 过滤器 0..4
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, width, height };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isValidAvatarVersion(value: unknown): value is string {
|
|
143
|
+
return typeof value === "string" && AVATAR_VERSION_RE.test(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function avatarUrl(userId: string, version: string): string {
|
|
147
|
+
return `/api/users/${encodeURIComponent(userId)}/avatar/${version}.png`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 只接受 multipart 里的单张图;多余字段一律拒绝,避免「顺手塞个 filename 覆盖路径」。 */
|
|
151
|
+
export function isMultipart(contentType: string | null): boolean {
|
|
152
|
+
return !!contentType && /^multipart\/form-data\b/i.test(contentType);
|
|
153
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { register, createNetworkTokenForNode, addNetworkMember } from "./auth.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { inspectAvatarImage, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
|
|
5
|
+
import { deflateSync } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
// 1×1 PNG(透明)。只用来走真实 multipart → 真实文件头校验。
|
|
8
|
+
const PNG_1X1 = Buffer.from(
|
|
9
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
10
|
+
"base64",
|
|
11
|
+
);
|
|
12
|
+
/** 真的 8-bit RGBA PNG:IHDR + 一段 zlib 压缩的扫描行 + IEND,CRC 全对。 */
|
|
13
|
+
function realPng(width: number, height: number): Buffer {
|
|
14
|
+
const crcTable = new Uint32Array(256).map((_, n) => {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
17
|
+
return c >>> 0;
|
|
18
|
+
});
|
|
19
|
+
const crc = (buf: Buffer) => {
|
|
20
|
+
let c = 0xffffffff;
|
|
21
|
+
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
22
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
23
|
+
};
|
|
24
|
+
const chunk = (type: string, data: Buffer) => {
|
|
25
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
26
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
27
|
+
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body));
|
|
28
|
+
return Buffer.concat([len, body, sum]);
|
|
29
|
+
};
|
|
30
|
+
const ihdr = Buffer.alloc(13);
|
|
31
|
+
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
32
|
+
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // 8-bit RGBA
|
|
33
|
+
const raw = Buffer.alloc((1 + width * 4) * height); // 每行前面一个 filter 字节 0
|
|
34
|
+
return Buffer.concat([
|
|
35
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
36
|
+
chunk("IHDR", ihdr),
|
|
37
|
+
chunk("IDAT", deflateSync(raw)),
|
|
38
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
/** CRC 全对、但 IDAT 内容非法的 PNG:用来证明「结构通过 ≠ 图像有效」。 */
|
|
44
|
+
function pngWithIdat(idat: Buffer, opts: { colorType?: number; bitDepth?: number; width?: number; height?: number; interlace?: number } = {}): Buffer {
|
|
45
|
+
const crcTable = new Uint32Array(256).map((_, n) => {
|
|
46
|
+
let c = n;
|
|
47
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
48
|
+
return c >>> 0;
|
|
49
|
+
});
|
|
50
|
+
const crc = (buf: Buffer) => {
|
|
51
|
+
let c = 0xffffffff;
|
|
52
|
+
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
53
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
54
|
+
};
|
|
55
|
+
const chunk = (type: string, data: Buffer) => {
|
|
56
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
57
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
58
|
+
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body));
|
|
59
|
+
return Buffer.concat([len, body, sum]);
|
|
60
|
+
};
|
|
61
|
+
const ihdr = Buffer.alloc(13);
|
|
62
|
+
ihdr.writeUInt32BE(opts.width ?? 1, 0); ihdr.writeUInt32BE(opts.height ?? 1, 4);
|
|
63
|
+
ihdr[8] = opts.bitDepth ?? 8; ihdr[9] = opts.colorType ?? 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = opts.interlace ?? 0;
|
|
64
|
+
return Buffer.concat([
|
|
65
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
66
|
+
chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0)),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("account avatar upload: user token only, PNG limits, visibility by shared network, revoke, atomic replace", async () => {
|
|
71
|
+
const a = register("avup_alice", "AvatarUpload1!");
|
|
72
|
+
const b = register("avup_bob", "AvatarUpload1!");
|
|
73
|
+
const outsider = register("avup_outsider", "AvatarUpload1!");
|
|
74
|
+
expect(a.ok && b.ok && outsider.ok).toBe(true);
|
|
75
|
+
expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
|
|
76
|
+
const node = createNetworkTokenForNode(a.user!.user_id, a.network_id!, "avup_alice", "avup_node");
|
|
77
|
+
expect(node.ok).toBe(true);
|
|
78
|
+
|
|
79
|
+
const { bootServer } = await import("./server.js");
|
|
80
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
81
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
82
|
+
|
|
83
|
+
const upload = async (token: string | null, bytes: Buffer, fieldName = "file", extra?: [string, string]) => {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: "image/png" }), "a.png");
|
|
86
|
+
if (extra) form.append(extra[0], extra[1]);
|
|
87
|
+
const headers: Record<string, string> = {};
|
|
88
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
89
|
+
const res = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers, body: form });
|
|
90
|
+
const body = await res.json().catch(() => null);
|
|
91
|
+
return { status: res.status, body: body as any };
|
|
92
|
+
};
|
|
93
|
+
const getAvatar = async (token: string | null, path: string) => {
|
|
94
|
+
const res = await fetch(base + path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
95
|
+
return { status: res.status, headers: res.headers, bytes: new Uint8Array(await res.arrayBuffer()) };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// 前置:陌生人之间没有共享网络。
|
|
100
|
+
expect(db.get("SELECT 1 AS ok FROM network_members m WHERE m.user_id=?1 AND m.network_id=?2", outsider.user!.user_id, a.network_id!)).toBeNull();
|
|
101
|
+
|
|
102
|
+
// 匿名/节点令牌都不是「本人」。
|
|
103
|
+
expect((await upload(null, PNG_1X1)).status).toBe(401);
|
|
104
|
+
expect((await upload(node.token!, PNG_1X1)).status).toBe(403);
|
|
105
|
+
|
|
106
|
+
// 畸形输入:非 PNG、超边长、空、多字段、非 multipart。
|
|
107
|
+
expect((await upload(a.token!, Buffer.from("not a png"))).body.error).toBe("invalid_image");
|
|
108
|
+
expect((await upload(a.token!, realPng(600, 600))).body.reason).toBe("too_big");
|
|
109
|
+
expect((await upload(a.token!, Buffer.alloc(0))).status).toBe(400);
|
|
110
|
+
expect((await upload(a.token!, PNG_1X1, "other")).status).toBe(400);
|
|
111
|
+
expect((await upload(a.token!, PNG_1X1, "file", ["avatar_url", "/etc/passwd"])).status).toBe(400);
|
|
112
|
+
const notMultipart = await fetch(`${base}/api/auth/me/avatar/upload`, {
|
|
113
|
+
method: "POST", headers: { Authorization: `Bearer ${a.token}`, "Content-Type": "application/json" }, body: "{}",
|
|
114
|
+
});
|
|
115
|
+
expect(notMultipart.status).toBe(415);
|
|
116
|
+
|
|
117
|
+
// 成功:URL 由 Hub 发,version 只含 URL 安全字符。
|
|
118
|
+
const ok1 = await upload(a.token!, PNG_1X1);
|
|
119
|
+
expect(ok1.status).toBe(200);
|
|
120
|
+
expect(ok1.body.ok).toBe(true);
|
|
121
|
+
expect(ok1.body.user_id).toBe(a.user!.user_id);
|
|
122
|
+
expect(ok1.body.avatar_url).toMatch(new RegExp(`^/api/users/${a.user!.user_id}/avatar/[A-Za-z0-9_-]{1,128}\\.png$`));
|
|
123
|
+
|
|
124
|
+
// 资料读取面必须同步看到这张头像(同一事务写 users.avatar_url)。
|
|
125
|
+
const meAfter = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${a.token}` } });
|
|
126
|
+
const meBody = await meAfter.json() as any;
|
|
127
|
+
expect(meBody.user.avatar_url).toBe(ok1.body.avatar_url);
|
|
128
|
+
|
|
129
|
+
const first = await getAvatar(a.token!, ok1.body.avatar_url);
|
|
130
|
+
expect(first.status).toBe(200);
|
|
131
|
+
expect(first.headers.get("content-type")).toBe("image/png");
|
|
132
|
+
expect(first.headers.get("cache-control")).toBe("private, no-store");
|
|
133
|
+
expect(first.headers.get("location")).toBeNull();
|
|
134
|
+
expect(first.bytes.byteLength).toBe(PNG_1X1.byteLength);
|
|
135
|
+
|
|
136
|
+
// 同网络成员可读;不同网络的人读不到(与不存在同码,避免探测)。
|
|
137
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(200);
|
|
138
|
+
expect((await getAvatar(outsider.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
139
|
+
expect((await getAvatar(null, ok1.body.avatar_url)).status).toBe(401);
|
|
140
|
+
expect((await getAvatar(node.token!, ok1.body.avatar_url)).status).toBe(403);
|
|
141
|
+
expect((await getAvatar(a.token!, `/api/users/${a.user!.user_id}/avatar/nope..%2F.png`)).status).toBe(404);
|
|
142
|
+
|
|
143
|
+
// 撤权后立刻读不到。
|
|
144
|
+
expect(db.run("DELETE FROM network_members WHERE user_id=?1 AND network_id=?2", [b.user!.user_id, a.network_id!]).changes).toBeGreaterThan(0);
|
|
145
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
146
|
+
|
|
147
|
+
// 「CRC 正确但图像数据非法」也必须 400:结构通过不等于可解码。
|
|
148
|
+
const beforeInvalid = db.get<{ version: string; bytes: number }>(
|
|
149
|
+
"SELECT version, bytes FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id);
|
|
150
|
+
expect((await upload(a.token!, pngWithIdat(Buffer.alloc(0)))).body.reason).toBe("bad_image");
|
|
151
|
+
expect((await upload(a.token!, pngWithIdat(Buffer.from("not-a-zlib-stream")))).body.reason).toBe("bad_image");
|
|
152
|
+
// 解压成功但扫描行长度不符(声明 2×2 RGBA,实际只给 1×1)
|
|
153
|
+
expect((await upload(a.token!, pngWithIdat(deflateSync(Buffer.alloc(5)), { width: 2, height: 2 }))).body.reason).toBe("bad_image");
|
|
154
|
+
const afterInvalid = db.get<{ version: string; bytes: number }>(
|
|
155
|
+
"SELECT version, bytes FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id);
|
|
156
|
+
expect(afterInvalid).toEqual(beforeInvalid);
|
|
157
|
+
|
|
158
|
+
// P2:清空后旧托管 URL 立即失效(本人与同网络成员都不能再读到)。
|
|
159
|
+
const putProfile = async (token: string, avatar_url: string | null) => {
|
|
160
|
+
const res = await fetch(`${base}/api/auth/me/avatar`, {
|
|
161
|
+
method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ avatar_url }),
|
|
163
|
+
});
|
|
164
|
+
return { status: res.status, body: await res.json() as any };
|
|
165
|
+
};
|
|
166
|
+
// 重新造一张可读的:此刻 b 已被撤权,先用 outsider 之外的同网成员做读校验
|
|
167
|
+
expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
|
|
168
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(200);
|
|
169
|
+
expect((await putProfile(a.token!, null)).status).toBe(200);
|
|
170
|
+
expect((await getAvatar(a.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
171
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
172
|
+
const meCleared = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${a.token}` } });
|
|
173
|
+
expect(((await meCleared.json()) as any).user.avatar_url).toBeNull();
|
|
174
|
+
expect(db.get("SELECT 1 AS ok FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id])).toBeNull();
|
|
175
|
+
|
|
176
|
+
// 换成池里的一张(非托管 URL)也必须撤销旧托管行。
|
|
177
|
+
const again = await upload(a.token!, PNG_1X1);
|
|
178
|
+
expect(again.status).toBe(200);
|
|
179
|
+
expect((await getAvatar(a.token!, again.body.avatar_url)).status).toBe(200);
|
|
180
|
+
expect((await putProfile(a.token!, "/avatars/avatar-03.webp")).status).toBe(200);
|
|
181
|
+
expect((await getAvatar(a.token!, again.body.avatar_url)).status).toBe(404);
|
|
182
|
+
expect(db.get("SELECT 1 AS ok FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id])).toBeNull();
|
|
183
|
+
|
|
184
|
+
// 原子替换:一次失败的上传不能把上一张弄丢;成功后旧 version 立刻 404。
|
|
185
|
+
const baseline = await upload(a.token!, PNG_1X1);
|
|
186
|
+
expect(baseline.status).toBe(200);
|
|
187
|
+
const beforeBad = db.get<{ version: string }>("SELECT version FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id]);
|
|
188
|
+
expect(beforeBad?.version).toBeTruthy();
|
|
189
|
+
expect((await upload(a.token!, Buffer.from("broken"))).status).toBe(400);
|
|
190
|
+
expect(db.get<{ version: string }>("SELECT version FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id)!.version).toBe(beforeBad!.version);
|
|
191
|
+
expect((await getAvatar(a.token!, baseline.body.avatar_url)).status).toBe(200);
|
|
192
|
+
|
|
193
|
+
const ok2 = await upload(a.token!, realPng(512, 512));
|
|
194
|
+
expect(ok2.status).toBe(200);
|
|
195
|
+
expect(ok2.body.avatar_url).not.toBe(baseline.body.avatar_url);
|
|
196
|
+
expect((await getAvatar(a.token!, baseline.body.avatar_url)).status).toBe(404);
|
|
197
|
+
expect((await getAvatar(a.token!, ok2.body.avatar_url)).status).toBe(200);
|
|
198
|
+
expect(db.get<{ width: number }>("SELECT width FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id)!.width).toBe(512);
|
|
199
|
+
} finally {
|
|
200
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id IN (?1, ?2, ?3)", [a.user!.user_id, b.user!.user_id, outsider.user!.user_id]);
|
|
201
|
+
await hub.stop?.();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("deleted network leaves only ghost membership: member loses read, owner keeps it", async () => {
|
|
206
|
+
const owner = register("avup_netowner", "AvatarUpload1!");
|
|
207
|
+
const member = register("avup_netmember", "AvatarUpload1!");
|
|
208
|
+
expect(owner.ok && member.ok).toBe(true);
|
|
209
|
+
expect(addNetworkMember(owner.network_id!, member.user!.user_id, "member").ok).toBe(true);
|
|
210
|
+
const { bootServer } = await import("./server.js");
|
|
211
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
212
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
213
|
+
const read = async (token: string, path: string) => (await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } })).status;
|
|
214
|
+
try {
|
|
215
|
+
const form = new FormData();
|
|
216
|
+
form.append("file", new Blob([new Uint8Array(PNG_1X1)], { type: "image/png" }), "a.png");
|
|
217
|
+
const up = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers: { Authorization: `Bearer ${owner.token}` }, body: form });
|
|
218
|
+
expect(up.status).toBe(200);
|
|
219
|
+
const url = ((await up.json()) as any).avatar_url as string;
|
|
220
|
+
expect(await read(member.token!, url)).toBe(200);
|
|
221
|
+
// 只删网络行,故意留下 network_members 残行:live membership 必须据此拒绝。
|
|
222
|
+
db.run("DELETE FROM networks WHERE network_id = ?1", [owner.network_id!]);
|
|
223
|
+
expect(await read(member.token!, url)).toBe(404);
|
|
224
|
+
expect(await read(owner.token!, url)).toBe(200); // 本人不依赖成员关系
|
|
225
|
+
} finally {
|
|
226
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [owner.user!.user_id]);
|
|
227
|
+
db.run("UPDATE users SET avatar_url = NULL WHERE user_id = ?1", [owner.user!.user_id]);
|
|
228
|
+
await hub.stop?.();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("upload is rate limited before multipart parsing, and rejects keepalive leftovers", async () => {
|
|
233
|
+
const u = register("avup_ratelimit", "AvatarUpload1!");
|
|
234
|
+
expect(u.ok).toBe(true);
|
|
235
|
+
const { bootServer } = await import("./server.js");
|
|
236
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
237
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
238
|
+
const send = async (body: BodyInit | null, contentType?: string) => {
|
|
239
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${u.token}` };
|
|
240
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
241
|
+
const res = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers, body });
|
|
242
|
+
return { status: res.status, connection: res.headers.get("connection"), body: await res.json().catch(() => null) as any };
|
|
243
|
+
};
|
|
244
|
+
const multipart = () => {
|
|
245
|
+
const form = new FormData();
|
|
246
|
+
form.append("file", new Blob([PNG_1X1], { type: "image/png" }), "a.png");
|
|
247
|
+
return form;
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
// 60 次成功用满这一小时的桶(每张 1×1,成本可忽略)。
|
|
251
|
+
for (let i = 0; i < 60; i++) {
|
|
252
|
+
const r = await send(multipart());
|
|
253
|
+
expect(r.status).toBe(200);
|
|
254
|
+
}
|
|
255
|
+
const limited = await send(multipart());
|
|
256
|
+
expect(limited.status).toBe(429);
|
|
257
|
+
expect(limited.body.error).toBe("rate_limited");
|
|
258
|
+
expect(limited.connection).toBe("close");
|
|
259
|
+
// 关键:限流在 multipart 解析之前——普通 JSON 也被先拒成 429,而不是 415。
|
|
260
|
+
const early = await send("{}", "application/json");
|
|
261
|
+
expect(early.status).toBe(429);
|
|
262
|
+
expect(early.connection).toBe("close");
|
|
263
|
+
} finally {
|
|
264
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [u.user!.user_id]);
|
|
265
|
+
db.run("UPDATE users SET avatar_url = NULL WHERE user_id = ?1", [u.user!.user_id]);
|
|
266
|
+
await hub.stop?.();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("image inspector: signature, shape and size limits", () => {
|
|
271
|
+
expect(inspectAvatarImage(PNG_1X1)).toEqual({ ok: true, width: 1, height: 1 });
|
|
272
|
+
expect(inspectAvatarImage(new Uint8Array(0))).toEqual({ ok: false, reason: "empty" });
|
|
273
|
+
expect(inspectAvatarImage(new Uint8Array(10))).toEqual({ ok: false, reason: "not_png" });
|
|
274
|
+
expect(inspectAvatarImage(Buffer.from(realPng(16, 16).subarray(0, 20)))).toEqual({ ok: false, reason: "truncated" });
|
|
275
|
+
expect(inspectAvatarImage(Buffer.from(realPng(16, 16).subarray(0, 40)))).toEqual({ ok: false, reason: "truncated" });
|
|
276
|
+
const truePng = realPng(16, 16);
|
|
277
|
+
const corrupt = Buffer.from(truePng); corrupt[corrupt.length - 20] ^= 0xff;
|
|
278
|
+
expect(inspectAvatarImage(corrupt).ok).toBe(false);
|
|
279
|
+
expect(inspectAvatarImage(realPng(513, 16))).toEqual({ ok: false, reason: "too_big" });
|
|
280
|
+
// Adam7 隔行:明确拒收(不按非隔行长度错算)
|
|
281
|
+
const interlaced = pngWithIdat(deflateSync(Buffer.alloc(6)), { width: 1, height: 1, interlace: 1 });
|
|
282
|
+
expect(inspectAvatarImage(interlaced)).toEqual({ ok: false, reason: "interlaced_unsupported" });
|
|
283
|
+
// 规范外取值 2..255:同样拒收(负控:扫描数据本来是非隔行的合法布局,
|
|
284
|
+
// 只有 interlace 字节非法,证明拦截来自 interlace 判断而不是解码失败)
|
|
285
|
+
for (const bad of [2, 3, 128, 255]) {
|
|
286
|
+
const invalidInterlace = pngWithIdat(deflateSync(Buffer.alloc(6)), { width: 1, height: 1, interlace: bad });
|
|
287
|
+
expect(inspectAvatarImage(invalidInterlace)).toEqual({ ok: false, reason: "interlaced_unsupported" });
|
|
288
|
+
}
|
|
289
|
+
const zeroWide = realPng(0, 1); // 宽 0 + 正确 CRC:专证 bad_dimensions 而不是 bad_crc
|
|
290
|
+
expect(inspectAvatarImage(zeroWide)).toEqual({ ok: false, reason: "bad_dimensions" });
|
|
291
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(40).fill(0)]);
|
|
292
|
+
expect(inspectAvatarImage(jpeg)).toEqual({ ok: false, reason: "not_png" });
|
|
293
|
+
expect(inspectAvatarImage(Buffer.alloc(MAX_AVATAR_BYTES + 1))).toEqual({ ok: false, reason: "too_large" });
|
|
294
|
+
});
|