@lelouchhe/webagent 0.2.6 → 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.
- package/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- 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
|
+
}
|