@lelouchhe/webagent 0.3.0 → 0.4.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.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +96 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1202 -144
  40. package/lib/server.js +149 -33
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +624 -30
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
@@ -0,0 +1,972 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { extname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { generateShareToken } from "../tokens.js";
6
+ import { SanitizeError, sanitizeEventsForShare } from "./sanitize.js";
7
+ import { buildContentDisposition, isInlineMime } from "../attachments.js";
8
+ import { enrichStoredEventsForDisplay } from "../attachment-labels.js";
9
+ import { log } from "../log.js";
10
+ const slog = log.scope("share");
11
+ // In-flight dedup for concurrent POST /share on the same session.
12
+ // First caller does the work; concurrent callers await the same promise.
13
+ // Idempotent because the body re-checks for an existing preview before
14
+ // inserting.
15
+ const pendingShareCreates = new Map();
16
+ const MAX_TTL_HOURS = 168;
17
+ const IMAGE_MIME = {
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".gif": "image/gif",
22
+ ".webp": "image/webp",
23
+ ".svg": "image/svg+xml",
24
+ };
25
+ /**
26
+ * Content Security Policy applied to the public viewer surface only. Strict:
27
+ * no inline script, no inline style, no remote origins. Self-served assets +
28
+ * data: URIs for images (marked emits some). Report-only when enforce=false.
29
+ */
30
+ function viewerCsp(enforce) {
31
+ const name = enforce
32
+ ? "Content-Security-Policy"
33
+ : "Content-Security-Policy-Report-Only";
34
+ const value = [
35
+ "default-src 'self'",
36
+ "script-src 'self'",
37
+ "style-src 'self'",
38
+ "img-src 'self' data:",
39
+ "connect-src 'self'",
40
+ "object-src 'none'",
41
+ "base-uri 'self'",
42
+ "frame-ancestors 'none'",
43
+ "form-action 'none'",
44
+ ].join("; ");
45
+ return { name, value };
46
+ }
47
+ function readJson(req) {
48
+ return new Promise((resolve, reject) => {
49
+ const chunks = [];
50
+ req.on("data", (c) => chunks.push(c));
51
+ req.on("end", () => {
52
+ const s = Buffer.concat(chunks).toString();
53
+ if (!s) {
54
+ resolve({});
55
+ return;
56
+ }
57
+ try {
58
+ resolve(JSON.parse(s));
59
+ }
60
+ catch (e) {
61
+ reject(e instanceof Error ? e : new Error(String(e)));
62
+ }
63
+ });
64
+ req.on("error", reject);
65
+ });
66
+ }
67
+ function json(res, status, body) {
68
+ res.writeHead(status, {
69
+ "Content-Type": "application/json",
70
+ "Cache-Control": "no-store",
71
+ });
72
+ res.end(JSON.stringify(body));
73
+ }
74
+ /**
75
+ * Dispatch share-related routes. Returns true if the route was handled
76
+ * (response ended). When `config.enabled === false`, returns false
77
+ * immediately — share routes are invisible.
78
+ *
79
+ * URL space claimed:
80
+ * /s, /s/:token (viewer — C3)
81
+ * /api/v1/sessions/:id/share (preview create — C2 here)
82
+ * /api/v1/sessions/:id/share/preview (preview read — C2 here)
83
+ * /api/v1/sessions/:id/share/publish (activate — C3)
84
+ * /api/v1/shares, /api/v1/shares/:t (owner list/patch — C4)
85
+ * /api/v1/shared/:token (public viewer JSON — C3)
86
+ */
87
+ // eslint-disable-next-line complexity -- TODO: split per-method dispatch
88
+ export async function handleShareRoutes(req, res, deps) {
89
+ if (!deps.config.enabled)
90
+ return false;
91
+ const url = req.url ?? "/";
92
+ const method = req.method ?? "GET";
93
+ // Viewer static assets (CSS/JS) — served under /s/_/ so the share viewer
94
+ // is fully self-contained behind one URL prefix. CF Access / proxies only
95
+ // need to whitelist /s/* (not /js/*, /styles.*.css, etc.). Must come
96
+ // before the /s/:token match so `_` doesn't get parsed as a token.
97
+ const assetMatch = url.match(/^\/s\/_\/([A-Za-z0-9._-]+)\/?(?:\?.*)?$/);
98
+ if (assetMatch && method === "GET") {
99
+ await handleViewerAsset(res, deps, assetMatch[1]);
100
+ return true;
101
+ }
102
+ // Viewer image proxy — must come before general /s/:token HTML match.
103
+ const imgMatch = url.match(/^\/s\/([A-Za-z0-9_-]{24})\/attachments\/([^/?]+)\/?(?:\?.*)?$/);
104
+ if (imgMatch && method === "GET") {
105
+ await handleViewerImage(res, deps, imgMatch[1], decodeURIComponent(imgMatch[2]));
106
+ return true;
107
+ }
108
+ // Viewer HTML shell.
109
+ const viewerMatch = url.match(/^\/s\/([A-Za-z0-9_-]{24})\/?(?:\?.*)?$/);
110
+ if (viewerMatch && method === "GET") {
111
+ await handleViewerHtml(res, deps, viewerMatch[1]);
112
+ return true;
113
+ }
114
+ if (url === "/s" || url === "/s/") {
115
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
116
+ res.end("share token required");
117
+ return true;
118
+ }
119
+ // POST /api/v1/sessions/:id/share — create preview
120
+ const createMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/?(?:\?.*)?$/);
121
+ if (createMatch && method === "POST") {
122
+ await handlePreviewCreate(req, res, deps, decodeURIComponent(createMatch[1]));
123
+ return true;
124
+ }
125
+ // GET /api/v1/sessions/:id/share/preview — read preview + staleness
126
+ const previewMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/preview\/?(?:\?.*)?$/);
127
+ if (previewMatch && method === "GET") {
128
+ await handlePreviewRead(req, res, deps, decodeURIComponent(previewMatch[1]));
129
+ return true;
130
+ }
131
+ // POST /api/v1/sessions/:id/share/publish — promote preview to public
132
+ const publishMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/publish\/?(?:\?.*)?$/);
133
+ if (publishMatch && method === "POST") {
134
+ await handlePublish(req, res, deps, decodeURIComponent(publishMatch[1]));
135
+ return true;
136
+ }
137
+ // GET /api/v1/shared/:token/events — public viewer JSON (no auth)
138
+ const sharedEventsMatch = url.match(/^\/api\/v1\/shared\/([A-Za-z0-9_-]{24})\/events\/?(?:\?.*)?$/);
139
+ if (sharedEventsMatch && method === "GET") {
140
+ await handleSharedEvents(res, deps, sharedEventsMatch[1]);
141
+ return true;
142
+ }
143
+ const revokeMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/?(?:\?.*)?$/);
144
+ // DELETE /api/v1/sessions/:id/share — hard-delete share row
145
+ if (revokeMatch && method === "DELETE") {
146
+ await handleRevoke(req, res, deps, decodeURIComponent(revokeMatch[1]));
147
+ return true;
148
+ }
149
+ // PATCH /api/v1/sessions/:id/share — update display_name / owner_label
150
+ if (revokeMatch && method === "PATCH") {
151
+ await handlePatchLabel(req, res, deps, decodeURIComponent(revokeMatch[1]));
152
+ return true;
153
+ }
154
+ if (url.match(/^\/api\/v1\/share\/by\/?(?:\?.*)?$/) &&
155
+ (method === "GET" || method === "PUT")) {
156
+ if (method === "GET") {
157
+ // GET /api/v1/share/by — read default display_name preference
158
+ await handleByGet(req, res, deps);
159
+ }
160
+ else {
161
+ // PUT /api/v1/share/by — update default display_name preference
162
+ await handleByPut(req, res, deps);
163
+ }
164
+ return true;
165
+ }
166
+ // GET /api/v1/shares — owner's active share list
167
+ if (url.match(/^\/api\/v1\/shares\/?(?:\?.*)?$/) && method === "GET") {
168
+ await handleOwnerList(req, res, deps);
169
+ return true;
170
+ }
171
+ // Any other /api/v1/shares[/...] or /api/v1/shared/... miss → 404.
172
+ if (/^\/api\/v1\/sessions\/[^/]+\/share(?:\/|$|\?)/.test(url) ||
173
+ url === "/api/v1/shares" ||
174
+ url.startsWith("/api/v1/shares/") ||
175
+ url.startsWith("/api/v1/shared/") ||
176
+ url === "/api/v1/share" ||
177
+ url.startsWith("/api/v1/share/")) {
178
+ res.writeHead(404, { "Content-Type": "application/json" });
179
+ res.end(JSON.stringify({ error: "not found" }));
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+ const DEFAULT_DISPLAY_NAME_KEY = "share.default_display_name";
185
+ function resolveDisplayName(deps, validated) {
186
+ if (validated !== "")
187
+ return validated;
188
+ return deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
189
+ }
190
+ /**
191
+ * POST /api/v1/sessions/:id/share — create (or return existing) preview.
192
+ *
193
+ * share-plan §4.2 R1-c1: same-session dedup — if an un-activated preview
194
+ * already exists, return it verbatim. Only create new on miss.
195
+ *
196
+ * Body (all optional):
197
+ * ttl_hours: number — NULL/omitted falls back to config; 0 = never
198
+ * expire; >0 clamped to MAX_TTL_HOURS (168)
199
+ * display_name: str — shown as "by @<name>" in viewer footer
200
+ * owner_label: str — private owner-side label (full validation in C4)
201
+ */
202
+ async function handlePreviewCreate(req, res, deps, sessionId) {
203
+ const session = deps.store.getSession(sessionId);
204
+ if (!session) {
205
+ json(res, 404, { error: "session not found" });
206
+ return;
207
+ }
208
+ // 409 guard: block while the agent is actively streaming into this session.
209
+ if (deps.sessions?.getBusyKind(sessionId) === "agent") {
210
+ json(res, 409, {
211
+ error: "session busy",
212
+ detail: "此 session 正在接收 agent 输出,请等 agent 输出结束后再分享",
213
+ });
214
+ return;
215
+ }
216
+ let body;
217
+ try {
218
+ body = (await readJson(req));
219
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime defense; cast above lies to TS
220
+ if (body === null || typeof body !== "object")
221
+ body = {};
222
+ }
223
+ catch {
224
+ json(res, 400, { error: "invalid JSON body" });
225
+ return;
226
+ }
227
+ let ttlHours = null;
228
+ if (body.ttl_hours != null) {
229
+ if (!Number.isFinite(body.ttl_hours) || body.ttl_hours < 0) {
230
+ json(res, 400, { error: "ttl_hours must be a non-negative number" });
231
+ return;
232
+ }
233
+ ttlHours =
234
+ body.ttl_hours === 0
235
+ ? 0
236
+ : Math.min(Math.floor(body.ttl_hours), MAX_TTL_HOURS);
237
+ }
238
+ // Labels are validated via the same helper as PATCH (V3 unify) — bidi/
239
+ // control/size rejected at entry rather than silently dropped.
240
+ const dnResult = validateLabel(body.display_name, "display_name", 256);
241
+ if (!dnResult.ok) {
242
+ json(res, 400, { error: dnResult.reason });
243
+ return;
244
+ }
245
+ const olResult = validateLabel(body.owner_label, "owner_label", 1024);
246
+ if (!olResult.ok) {
247
+ json(res, 400, { error: olResult.reason });
248
+ return;
249
+ }
250
+ const displayName = resolveDisplayName(deps, dnResult.value);
251
+ const ownerLabel = olResult.value === "" ? null : olResult.value;
252
+ try {
253
+ const existingInflight = pendingShareCreates.get(sessionId);
254
+ const inflight = existingInflight ??
255
+ (async () => {
256
+ // Dedup first — existing preview short-circuits the gate.
257
+ const existing = deps.store.findActivePreviewBySession(sessionId);
258
+ if (existing)
259
+ return { row: existing, reused: true };
260
+ // Flush buffered chunks so snapshot_seq includes the streaming tail.
261
+ deps.sessions?.flushBuffers(sessionId);
262
+ const allEvents = deps.store.getEvents(sessionId);
263
+ const snapshotSeq = allEvents.length > 0 ? Math.max(...allEvents.map((e) => e.seq)) : 0;
264
+ // Gate: run the sanitizer on-write. Hard-rejects throw here so we
265
+ // never create a preview row for a session with leaked secrets.
266
+ runSanitizeGate(allEvents, session.cwd, deps.config.internal_hosts);
267
+ const token = generateShareToken();
268
+ const row = deps.store.insertSharePreview({
269
+ token,
270
+ sessionId,
271
+ snapshotSeq,
272
+ ttlHours,
273
+ displayName,
274
+ ownerLabel,
275
+ });
276
+ return { row, reused: false };
277
+ })().finally(() => {
278
+ pendingShareCreates.delete(sessionId);
279
+ });
280
+ if (!existingInflight)
281
+ pendingShareCreates.set(sessionId, inflight);
282
+ const result = await inflight;
283
+ json(res, result.reused ? 200 : 201, {
284
+ token: result.row.token,
285
+ session_id: sessionId,
286
+ snapshot_seq: result.row.share_snapshot_seq,
287
+ ttl_hours: result.row.ttl_hours,
288
+ display_name: result.row.display_name,
289
+ owner_label: result.row.owner_label,
290
+ shared_at: result.row.shared_at,
291
+ reused: result.reused,
292
+ });
293
+ }
294
+ catch (err) {
295
+ if (err instanceof SanitizeError) {
296
+ json(res, 400, {
297
+ error: "sanitize rejected",
298
+ event_id: err.event_id,
299
+ rule: err.rule,
300
+ detail: err.message,
301
+ });
302
+ return;
303
+ }
304
+ const errorId = randomUUID();
305
+ slog.error("preview_create", { error_id: errorId, error: err });
306
+ json(res, 500, { error: "internal error", error_id: errorId });
307
+ }
308
+ }
309
+ function runSanitizeGate(events, cwd, internalHosts) {
310
+ // sanitizeEventsForShare throws SanitizeError on hard-reject.
311
+ sanitizeEventsForShare({
312
+ events,
313
+ cwd,
314
+ homeDir: homedir(),
315
+ internalHosts,
316
+ });
317
+ }
318
+ /**
319
+ * GET /api/v1/sessions/:id/share/preview — read sanitized preview.
320
+ *
321
+ * Auth: owner + X-Share-Token header (token never in URL, never in logs).
322
+ * Returns a `{schema_version, events, share}` bundle matching the public
323
+ * viewer contract (minus public-only fields) so the overlay can share
324
+ * the render path.
325
+ */
326
+ async function handlePreviewRead(req, res, deps, sessionId) {
327
+ const tokenHeader = req.headers["x-share-token"];
328
+ const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
329
+ if (!token) {
330
+ json(res, 400, { error: "X-Share-Token header required" });
331
+ return;
332
+ }
333
+ const row = deps.store.getShareByToken(token);
334
+ if (!row) {
335
+ json(res, 404, { error: "share not found" });
336
+ return;
337
+ }
338
+ if (row.session_id !== sessionId) {
339
+ json(res, 404, { error: "share not found" });
340
+ return;
341
+ }
342
+ if (row.shared_at != null) {
343
+ json(res, 409, { error: "share already active (use public viewer)" });
344
+ return;
345
+ }
346
+ const session = deps.store.getSession(sessionId);
347
+ if (!session) {
348
+ json(res, 404, { error: "session not found" });
349
+ return;
350
+ }
351
+ const allEvents = deps.store
352
+ .getEvents(sessionId)
353
+ .filter((e) => e.seq <= row.share_snapshot_seq);
354
+ if (deps.sessions) {
355
+ enrichStoredEventsForDisplay(allEvents, deps.sessions.getLabelMap(sessionId));
356
+ }
357
+ const currentLastSeq = deps.store
358
+ .getEvents(sessionId)
359
+ .reduce((m, e) => Math.max(m, e.seq), 0);
360
+ try {
361
+ const { events } = sanitizeEventsForShare({
362
+ events: allEvents,
363
+ cwd: session.cwd,
364
+ homeDir: homedir(),
365
+ internalHosts: deps.config.internal_hosts,
366
+ });
367
+ // Staleness metadata drives the owner sticky bar text (§2.1 R2-c3).
368
+ const eventsSinceSnapshot = Math.max(0, currentLastSeq - row.share_snapshot_seq);
369
+ json(res, 200, {
370
+ schema_version: "1.0",
371
+ share: {
372
+ token: row.token,
373
+ session_id: sessionId,
374
+ session_title: session.title,
375
+ shared_at: null,
376
+ snapshot_seq: row.share_snapshot_seq,
377
+ current_last_seq: currentLastSeq,
378
+ events_since_snapshot: eventsSinceSnapshot,
379
+ created_at: row.created_at,
380
+ display_name: row.display_name,
381
+ owner_label: row.owner_label,
382
+ ttl_hours: row.ttl_hours,
383
+ },
384
+ events,
385
+ });
386
+ }
387
+ catch (err) {
388
+ if (err instanceof SanitizeError) {
389
+ json(res, 400, {
390
+ error: "sanitize rejected",
391
+ event_id: err.event_id,
392
+ rule: err.rule,
393
+ });
394
+ return;
395
+ }
396
+ const errorId = randomUUID();
397
+ slog.error("preview_read", { error_id: errorId, error: err });
398
+ json(res, 500, { error: "internal error", error_id: errorId });
399
+ }
400
+ }
401
+ /**
402
+ * POST /api/v1/sessions/:id/share/publish — activate an existing preview.
403
+ *
404
+ * Body: { token, display_name?, owner_label? }
405
+ * - token MUST match a preview row for this session that has not been
406
+ * activated or revoked.
407
+ * - display_name / owner_label, if present, overwrite the preview row and
408
+ * are persisted into owner_prefs so the next /share defaults to them.
409
+ *
410
+ * Response: { token, session_id, shared_at, display_name, owner_label,
411
+ * public_url } on 200; 404/409/410 on state errors.
412
+ */
413
+ // eslint-disable-next-line complexity -- TODO: split validation / state-update / response phases
414
+ async function handlePublish(req, res, deps, sessionId) {
415
+ let body;
416
+ try {
417
+ body = (await readJson(req));
418
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime defense; cast above lies to TS
419
+ if (body === null || typeof body !== "object")
420
+ body = {};
421
+ }
422
+ catch {
423
+ json(res, 400, { error: "invalid JSON body" });
424
+ return;
425
+ }
426
+ if (!body.token || typeof body.token !== "string") {
427
+ json(res, 400, { error: "token required" });
428
+ return;
429
+ }
430
+ const row = deps.store.getShareByToken(body.token);
431
+ if (!row) {
432
+ json(res, 404, { error: "share not found" });
433
+ return;
434
+ }
435
+ if (row.session_id !== sessionId) {
436
+ json(res, 404, { error: "share not found" });
437
+ return;
438
+ }
439
+ if (row.shared_at != null) {
440
+ json(res, 409, {
441
+ error: "share already active",
442
+ token: row.token,
443
+ shared_at: row.shared_at,
444
+ });
445
+ return;
446
+ }
447
+ // V3: publish uses the same validator as PATCH. Previously non-string or
448
+ // over-limit input was silently coerced to null, overwriting whatever the
449
+ // preview row (or an earlier PATCH) had set. Now reject at the edge.
450
+ let displayName;
451
+ if ("display_name" in body) {
452
+ const r = validateLabel(body.display_name, "display_name", 256);
453
+ if (!r.ok) {
454
+ json(res, 400, { error: r.reason });
455
+ return;
456
+ }
457
+ displayName = r.value === "" ? null : r.value;
458
+ }
459
+ let ownerLabel;
460
+ if ("owner_label" in body) {
461
+ const r = validateLabel(body.owner_label, "owner_label", 1024);
462
+ if (!r.ok) {
463
+ json(res, 400, { error: r.reason });
464
+ return;
465
+ }
466
+ ownerLabel = r.value === "" ? null : r.value;
467
+ }
468
+ const activated = deps.store.activateShare(body.token, {
469
+ ...(displayName !== undefined && { displayName }),
470
+ ...(ownerLabel !== undefined && { ownerLabel }),
471
+ });
472
+ if (!activated) {
473
+ // Race: concurrent revoke/activate between getShareByToken and activateShare.
474
+ const fresh = deps.store.getShareByToken(body.token);
475
+ if (!fresh) {
476
+ json(res, 410, { error: "share revoked" });
477
+ return;
478
+ }
479
+ if (fresh.shared_at != null) {
480
+ json(res, 409, {
481
+ error: "share already active",
482
+ token: fresh.token,
483
+ shared_at: fresh.shared_at,
484
+ });
485
+ return;
486
+ }
487
+ json(res, 500, { error: "unexpected activate failure" });
488
+ return;
489
+ }
490
+ if (displayName !== undefined && displayName != null) {
491
+ deps.store.setOwnerPref("share.default_display_name", displayName);
492
+ }
493
+ const after = deps.store.getShareByToken(body.token);
494
+ if (!after) {
495
+ json(res, 500, { error: "post-activate read failed" });
496
+ return;
497
+ }
498
+ const origin = deps.config.viewer_origin && deps.config.viewer_origin !== ""
499
+ ? deps.config.viewer_origin.replace(/\/$/, "")
500
+ : "";
501
+ json(res, 200, {
502
+ token: after.token,
503
+ session_id: sessionId,
504
+ shared_at: after.shared_at,
505
+ display_name: after.display_name,
506
+ owner_label: after.owner_label,
507
+ public_url: `${origin}/s/${after.token}`,
508
+ });
509
+ }
510
+ /**
511
+ * GET /s/:token — public viewer HTML shell. Sets a strict CSP header. No
512
+ * owner auth; the viewer JS will fetch /api/v1/shared/:token/events.
513
+ */
514
+ async function handleViewerHtml(res, deps, token) {
515
+ const row = deps.store.getShareByToken(token);
516
+ if (row?.shared_at == null) {
517
+ // Preview tokens (shared_at IS NULL) MUST NOT resolve publicly.
518
+ const csp = viewerCsp(deps.config.csp_enforce);
519
+ res.writeHead(410, {
520
+ "Content-Type": "text/html; charset=utf-8",
521
+ [csp.name]: csp.value,
522
+ "Cache-Control": "no-store",
523
+ "X-Robots-Tag": "noindex, nofollow",
524
+ });
525
+ res.end("<!doctype html><html><body><h1>410</h1><p>此链接已撤销或过期。</p></body></html>");
526
+ return;
527
+ }
528
+ if (isExpired(row)) {
529
+ const csp = viewerCsp(deps.config.csp_enforce);
530
+ res.writeHead(410, {
531
+ "Content-Type": "text/html; charset=utf-8",
532
+ [csp.name]: csp.value,
533
+ "Cache-Control": "no-store",
534
+ "X-Robots-Tag": "noindex, nofollow",
535
+ });
536
+ res.end("<!doctype html><html><body><h1>410</h1><p>此链接已过期。</p></body></html>");
537
+ return;
538
+ }
539
+ if (!deps.publicDir) {
540
+ json(res, 500, { error: "publicDir not configured" });
541
+ return;
542
+ }
543
+ try {
544
+ const html = await readFile(join(deps.publicDir, "share-viewer.html"), "utf-8");
545
+ const csp = viewerCsp(deps.config.csp_enforce);
546
+ res.writeHead(200, {
547
+ "Content-Type": "text/html; charset=utf-8",
548
+ [csp.name]: csp.value,
549
+ "Cache-Control": "no-store, no-cache, must-revalidate",
550
+ "X-Frame-Options": "DENY",
551
+ "Referrer-Policy": "no-referrer",
552
+ "X-Robots-Tag": "noindex, nofollow",
553
+ });
554
+ res.end(html);
555
+ // Fire-and-forget: bump last_accessed_at so prune logic can age untouched shares.
556
+ deps.store.touchShareAccessed(token);
557
+ }
558
+ catch (err) {
559
+ const errorId = randomUUID();
560
+ slog.error("viewer_html", { error_id: errorId, error: err });
561
+ res.writeHead(500, { "Content-Type": "text/plain" });
562
+ res.end(`viewer unavailable (error_id=${errorId})`);
563
+ }
564
+ }
565
+ /**
566
+ * GET /api/v1/shared/:token/events — public JSON. Re-runs the sanitizer
567
+ * on every call (cached by projection LRU). No owner auth.
568
+ */
569
+ async function handleSharedEvents(res, deps, token) {
570
+ const row = deps.store.getShareByToken(token);
571
+ if (row?.shared_at == null) {
572
+ json(res, 410, { error: "share revoked or not found" });
573
+ return;
574
+ }
575
+ if (isExpired(row)) {
576
+ json(res, 410, { error: "share expired" });
577
+ return;
578
+ }
579
+ // Public viewer must keep working after the owner deletes the source
580
+ // session — events stay alive as long as any active share references
581
+ // them (Store.deleteSession soft-deletes when shares exist).
582
+ const session = deps.store.getSessionIncludingDeleted(row.session_id);
583
+ if (!session) {
584
+ json(res, 500, { error: "session vanished" });
585
+ return;
586
+ }
587
+ const allEvents = deps.store
588
+ .getEvents(row.session_id)
589
+ .filter((e) => e.seq <= row.share_snapshot_seq);
590
+ if (deps.sessions) {
591
+ enrichStoredEventsForDisplay(allEvents, deps.sessions.getLabelMap(row.session_id));
592
+ }
593
+ try {
594
+ const { events } = sanitizeEventsForShare({
595
+ events: allEvents,
596
+ cwd: session.cwd,
597
+ homeDir: homedir(),
598
+ internalHosts: deps.config.internal_hosts,
599
+ });
600
+ // Public response: DOES NOT expose session_id. Only title + display_name + meta.
601
+ res.writeHead(200, {
602
+ "Content-Type": "application/json",
603
+ "Cache-Control": "no-store",
604
+ "X-Content-Type-Options": "nosniff",
605
+ });
606
+ res.end(JSON.stringify({
607
+ schema_version: "1.0",
608
+ share: {
609
+ token: row.token,
610
+ session_title: session.title,
611
+ shared_at: row.shared_at,
612
+ snapshot_seq: row.share_snapshot_seq,
613
+ display_name: row.display_name,
614
+ created_at: row.created_at,
615
+ ttl_hours: row.ttl_hours,
616
+ },
617
+ events,
618
+ }));
619
+ }
620
+ catch (err) {
621
+ if (err instanceof SanitizeError) {
622
+ // Hard-reject on a LIVE active share — owner's session gained a
623
+ // post-publish leak. Return 410 publicly; owner sees root cause via
624
+ // preview re-gate.
625
+ slog.error("shared_events hard-reject", {
626
+ rule: err.rule,
627
+ event_id: err.event_id,
628
+ });
629
+ json(res, 410, { error: "share unavailable" });
630
+ return;
631
+ }
632
+ const errorId = randomUUID();
633
+ slog.error("shared_events", { error_id: errorId, error: err });
634
+ json(res, 500, { error: "internal error", error_id: errorId });
635
+ }
636
+ }
637
+ /**
638
+ * GET /s/_/<file> — viewer-namespaced static asset proxy. Serves the same
639
+ * hashed CSS/JS bundles as the main app, but under a /s/* path so the
640
+ * viewer is fully self-contained behind one URL prefix (single CF Access
641
+ * bypass, no leakage of owner-only paths). Read-only allowlist of safe
642
+ * filename patterns; hashed bundles get immutable cache, dev-mode unhashed
643
+ * bundles get no-cache.
644
+ */
645
+ async function handleViewerAsset(res, deps, file) {
646
+ if (!deps.publicDir) {
647
+ res.writeHead(500, { "Content-Type": "text/plain" });
648
+ res.end("publicDir not configured");
649
+ return;
650
+ }
651
+ // Strict filename allowlist — only the bundles share-viewer.html references.
652
+ // Hashed prod: styles.HASH.css, share-viewer.HASH.css, viewer.HASH.js, chunk.HASH.js
653
+ // Dev unhashed: styles.css, share-viewer.css, viewer.js
654
+ const cssMatch = /^(styles|share-viewer)(?:\.[A-Za-z0-9_-]+)?\.css$/.test(file);
655
+ const jsMatch = /^(viewer|chunk)(?:\.[A-Za-z0-9_-]+)?\.js$/.test(file);
656
+ if (!cssMatch && !jsMatch) {
657
+ res.writeHead(404, { "Content-Type": "text/plain" });
658
+ res.end("not found");
659
+ return;
660
+ }
661
+ const filePath = jsMatch
662
+ ? join(deps.publicDir, "js", file)
663
+ : join(deps.publicDir, file);
664
+ // Hashed bundles (X.HASH.css|js) are content-addressed → immutable.
665
+ // Dev unhashed bundles (X.css|js) revalidate every load.
666
+ const hashed = /\.[A-Za-z0-9_-]{6,}\.(css|js)$/.test(file);
667
+ const cacheControl = hashed
668
+ ? "public, max-age=31536000, immutable"
669
+ : "no-cache";
670
+ const contentType = jsMatch
671
+ ? "text/javascript; charset=utf-8"
672
+ : "text/css; charset=utf-8";
673
+ try {
674
+ const buf = await readFile(filePath);
675
+ res.writeHead(200, {
676
+ "Content-Type": contentType,
677
+ "Cache-Control": cacheControl,
678
+ "X-Content-Type-Options": "nosniff",
679
+ "X-Robots-Tag": "noindex, nofollow",
680
+ });
681
+ res.end(buf);
682
+ }
683
+ catch {
684
+ res.writeHead(404, { "Content-Type": "text/plain" });
685
+ res.end("not found");
686
+ }
687
+ }
688
+ /**
689
+ * GET /s/:token/attachments/:file — token-scoped image proxy. Resolves the token
690
+ * to a session_id on-demand; directly serving /api/v1/sessions/:id/images
691
+ * would leak session_id.
692
+ */
693
+ async function handleViewerImage(res, deps, token, file) {
694
+ if (!deps.dataDir) {
695
+ json(res, 500, { error: "dataDir not configured" });
696
+ return;
697
+ }
698
+ const row = deps.store.getShareByToken(token);
699
+ if (row?.shared_at == null) {
700
+ json(res, 410, { error: "share unavailable" });
701
+ return;
702
+ }
703
+ if (isExpired(row)) {
704
+ json(res, 410, { error: "share expired" });
705
+ return;
706
+ }
707
+ // Only allow simple filenames — reject any path separators / dotfiles / traversal.
708
+ if (!/^[A-Za-z0-9._-]+$/.test(file) || file.startsWith(".")) {
709
+ json(res, 404, { error: "invalid file" });
710
+ return;
711
+ }
712
+ const sessionRoot = join(deps.dataDir, "sessions", row.session_id, "attachments");
713
+ const filePath = join(sessionRoot, file);
714
+ // Final realpath-style guard: must stay under <dataDir>/sessions/<sid>/attachments.
715
+ if (!filePath.startsWith(sessionRoot + "/") && filePath !== sessionRoot) {
716
+ res.writeHead(403);
717
+ res.end("Forbidden");
718
+ return;
719
+ }
720
+ try {
721
+ const buf = await readFile(filePath);
722
+ // Look up the attachment row to recover the original mime + display
723
+ // name. Without this iOS Safari sees Content-Type: octet-stream and
724
+ // appends ".bin" to the <a download> name (e.g. zhihu.user.js →
725
+ // zhihu.user.js.bin). The owner-side route at routes.ts does the
726
+ // same lookup; share viewer needs parity for non-image attachments.
727
+ const att = deps.store.getAttachmentByFile(row.session_id, file);
728
+ const ext = extname(filePath).toLowerCase();
729
+ let mime = att?.mime;
730
+ mime ??= IMAGE_MIME[ext];
731
+ mime ??= "application/octet-stream";
732
+ const headers = {
733
+ "Content-Type": mime,
734
+ "Cache-Control": "public, max-age=3600",
735
+ "X-Content-Type-Options": "nosniff",
736
+ "Content-Security-Policy": "default-src 'none'",
737
+ "X-Robots-Tag": "noindex, nofollow",
738
+ };
739
+ if (att) {
740
+ const disposition = isInlineMime(mime) ? "inline" : "attachment";
741
+ headers["Content-Disposition"] = buildContentDisposition(disposition, att.name);
742
+ }
743
+ res.writeHead(200, headers);
744
+ res.end(buf);
745
+ }
746
+ catch {
747
+ res.writeHead(404, { "Content-Type": "text/plain" });
748
+ res.end("Not found");
749
+ }
750
+ }
751
+ function isExpired(row) {
752
+ if (row.ttl_hours == null || row.ttl_hours === 0)
753
+ return false;
754
+ const anchor = row.shared_at ?? row.created_at;
755
+ return Date.now() > anchor + row.ttl_hours * 3600_000;
756
+ }
757
+ /**
758
+ * Validate owner-supplied label/display_name text. Rules:
759
+ * - string only (null/undefined → empty string, treated as "unset")
760
+ * - UTF-8 byte length ≤ maxBytes (default 1024 for owner_label, 256 for display_name)
761
+ * - reject C0 controls (\x00..\x1f) except TAB (\t)
762
+ * - reject DEL (\x7f)
763
+ * - reject bidi override / isolate codepoints U+202A..U+202E, U+2066..U+2069
764
+ *
765
+ * Unpaired surrogates are intentionally NOT rejected. Labels are rendered
766
+ * via textContent in the viewer; a lone surrogate renders as U+FFFD replacement
767
+ * with no security implication. SQLite (WTF-8) and JSON.stringify both
768
+ * tolerate them.
769
+ *
770
+ * Returns `{ ok: true, value }` on accept, `{ ok: false, reason }` on reject.
771
+ * Empty string is accepted (caller decides semantics).
772
+ */
773
+ export function validateLabel(input, field, maxBytes = 1024) {
774
+ if (input == null)
775
+ return { ok: true, value: "" };
776
+ if (typeof input !== "string")
777
+ return { ok: false, reason: `${field} must be a string` };
778
+ if (Buffer.byteLength(input, "utf8") > maxBytes) {
779
+ return { ok: false, reason: `${field} exceeds ${maxBytes} bytes` };
780
+ }
781
+ // Iterate by Unicode codepoint (for..of uses the string iterator, which
782
+ // yields one codepoint per step — supplementary chars are not split into
783
+ // two surrogate halves). All checked ranges are in the BMP so charCodeAt
784
+ // would also work, but codepoint iteration keeps the code UTF-16 agnostic.
785
+ for (const ch of input) {
786
+ const cp = ch.codePointAt(0);
787
+ if (cp < 0x20 && cp !== 0x09)
788
+ return { ok: false, reason: `${field} contains control character` };
789
+ if (cp === 0x7f)
790
+ return { ok: false, reason: `${field} contains DEL character` };
791
+ if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069)) {
792
+ return { ok: false, reason: `${field} contains bidi override` };
793
+ }
794
+ }
795
+ return { ok: true, value: input };
796
+ }
797
+ /**
798
+ * DELETE /api/v1/sessions/:id/share — revoke an active or preview share.
799
+ * Body: { token }.
800
+ * Idempotent: already-revoked tokens return 200 with revoked=false.
801
+ * Returns { ok, token, revoked, purge_status }. purge_status is always
802
+ * 'skipped' in v1 — image/event purge is a future hardening pass.
803
+ */
804
+ async function handleRevoke(req, res, deps, sessionId) {
805
+ let body;
806
+ try {
807
+ body = (await readJson(req));
808
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime defense; cast above lies to TS
809
+ if (body === null || typeof body !== "object")
810
+ body = {};
811
+ }
812
+ catch {
813
+ json(res, 400, { error: "invalid JSON body" });
814
+ return;
815
+ }
816
+ if (!body.token || typeof body.token !== "string") {
817
+ json(res, 400, { error: "token required" });
818
+ return;
819
+ }
820
+ const row = deps.store.getShareByToken(body.token);
821
+ if (!row) {
822
+ // Idempotent DELETE: row already gone (revoked or never existed).
823
+ // We can't verify session ownership without a row, but the token
824
+ // is opaque/random so leaking "revoked or never existed" is fine.
825
+ json(res, 200, {
826
+ ok: true,
827
+ token: body.token,
828
+ revoked: false,
829
+ purge_status: "skipped",
830
+ });
831
+ return;
832
+ }
833
+ if (row.session_id !== sessionId) {
834
+ json(res, 404, { error: "share not found" });
835
+ return;
836
+ }
837
+ const revoked = deps.store.revokeShare(body.token);
838
+ // If this was the last share on a soft-deleted session, finish the
839
+ // hard-delete (events + sessions row) so we don't leak orphans.
840
+ if (revoked) {
841
+ const reaped = deps.store.reapTombstoneIfOrphaned(row.session_id);
842
+ if (reaped && deps.dataDir) {
843
+ // Tombstoned session is fully gone; sweep its attachments directory too.
844
+ rm(join(deps.dataDir, "sessions", row.session_id), {
845
+ recursive: true,
846
+ force: true,
847
+ }).catch(() => { });
848
+ }
849
+ }
850
+ json(res, 200, {
851
+ ok: true,
852
+ token: body.token,
853
+ revoked,
854
+ purge_status: "skipped",
855
+ });
856
+ }
857
+ /**
858
+ * PATCH /api/v1/sessions/:id/share — update owner_label / display_name on
859
+ * a live (non-revoked) share. Body: { token, owner_label?, display_name? }.
860
+ *
861
+ * Full validation: UTF-8 ≤1024B, no C0 controls (except TAB), no DEL, no
862
+ * bidi overrides. Fields omitted from body are left unchanged; fields set
863
+ * to empty string clear the value.
864
+ */
865
+ async function handlePatchLabel(req, res, deps, sessionId) {
866
+ let body;
867
+ try {
868
+ body = (await readJson(req));
869
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime defense; cast above lies to TS
870
+ if (body === null || typeof body !== "object")
871
+ body = {};
872
+ }
873
+ catch {
874
+ json(res, 400, { error: "invalid JSON body" });
875
+ return;
876
+ }
877
+ if (!body.token || typeof body.token !== "string") {
878
+ json(res, 400, { error: "token required" });
879
+ return;
880
+ }
881
+ const row = deps.store.getShareByToken(body.token);
882
+ if (!row) {
883
+ json(res, 404, { error: "share not found" });
884
+ return;
885
+ }
886
+ if (row.session_id !== sessionId) {
887
+ json(res, 404, { error: "share not found" });
888
+ return;
889
+ }
890
+ let ownerLabel = undefined;
891
+ if ("owner_label" in body) {
892
+ const v = validateLabel(body.owner_label, "owner_label", 1024);
893
+ if (!v.ok) {
894
+ json(res, 400, { error: v.reason });
895
+ return;
896
+ }
897
+ ownerLabel = v.value === "" ? null : v.value;
898
+ }
899
+ let displayName = undefined;
900
+ if ("display_name" in body) {
901
+ const v = validateLabel(body.display_name, "display_name", 256);
902
+ if (!v.ok) {
903
+ json(res, 400, { error: v.reason });
904
+ return;
905
+ }
906
+ displayName = v.value === "" ? null : v.value;
907
+ }
908
+ if (ownerLabel !== undefined)
909
+ deps.store.updateShareOwnerLabel(body.token, ownerLabel);
910
+ if (displayName !== undefined)
911
+ deps.store.updateShareDisplayName(body.token, displayName);
912
+ const after = deps.store.getShareByToken(body.token);
913
+ if (!after) {
914
+ json(res, 500, { error: "post-patch read failed" });
915
+ return;
916
+ }
917
+ json(res, 200, {
918
+ token: after.token,
919
+ session_id: sessionId,
920
+ owner_label: after.owner_label,
921
+ display_name: after.display_name,
922
+ });
923
+ }
924
+ /**
925
+ * GET /api/v1/shares — owner-only list of live (non-revoked) shares.
926
+ * Returns { shares: [...] } with preview + active rows separated by
927
+ * shared_at (null = preview).
928
+ */
929
+ async function handleOwnerList(req, res, deps) {
930
+ const rows = deps.store.listOwnerShares();
931
+ json(res, 200, { shares: rows });
932
+ }
933
+ /**
934
+ * GET /api/v1/share/by — read the owner's default display_name (used by
935
+ * the slash menu to surface the current value as secondary text).
936
+ * Returns { value: string | null }; null = not set / will publish anonymously.
937
+ */
938
+ async function handleByGet(req, res, deps) {
939
+ const value = deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
940
+ json(res, 200, { value });
941
+ }
942
+ /**
943
+ * PUT /api/v1/share/by — set or clear the owner's default display_name.
944
+ * Body { value: string | null }. null / empty string clears. Validation
945
+ * mirrors the publish/PATCH endpoints (≤256 bytes UTF-8, no controls/bidi).
946
+ */
947
+ async function handleByPut(req, res, deps) {
948
+ let body;
949
+ try {
950
+ body = (await readJson(req));
951
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime defense; cast above lies to TS
952
+ if (body === null || typeof body !== "object")
953
+ body = {};
954
+ }
955
+ catch {
956
+ json(res, 400, { error: "invalid JSON body" });
957
+ return;
958
+ }
959
+ const v = validateLabel(body.value, "value", 256);
960
+ if (!v.ok) {
961
+ json(res, 400, { error: v.reason });
962
+ return;
963
+ }
964
+ if (v.value === "") {
965
+ deps.store.clearOwnerPref(DEFAULT_DISPLAY_NAME_KEY);
966
+ json(res, 200, { value: null });
967
+ }
968
+ else {
969
+ deps.store.setOwnerPref(DEFAULT_DISPLAY_NAME_KEY, v.value);
970
+ json(res, 200, { value: v.value });
971
+ }
972
+ }