@lelouchhe/webagent 0.6.0 → 0.8.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/dist/index.html +15 -6
- package/dist/js/app.XBFXH37R.js +2 -0
- package/dist/js/chunk.CT5WBNGZ.js +173 -0
- package/dist/js/chunk.UMQMOGWO.js +1 -0
- package/dist/js/{login.PYIK52HN.js → login.2WA6DTGM.js} +1 -1
- package/dist/js/viewer.CVWXSKJM.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.01ky3cex.css → styles.00etlpgs.css} +94 -4
- package/dist/sw.js +19 -9
- package/lib/agent-commands.js +27 -0
- package/lib/bridge.js +60 -11
- package/lib/event-handler.js +75 -15
- package/lib/http-status.js +17 -0
- package/lib/message-cleanup.js +5 -4
- package/lib/push-service.js +2 -1
- package/lib/routes.js +468 -196
- package/lib/server.js +4 -3
- package/lib/session-manager.js +213 -23
- package/lib/session-state.js +70 -5
- package/lib/share/routes.js +106 -72
- package/lib/share/sanitize.js +2 -1
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +25 -23
- package/package.json +1 -1
- package/dist/js/app.HJKQURHR.js +0 -4
- package/dist/js/chunk.D4ZYHJAM.js +0 -1
- package/dist/js/chunk.ON2W5X2T.js +0 -173
- package/dist/js/viewer.VY4PO2O5.js +0 -1
package/lib/share/routes.js
CHANGED
|
@@ -7,6 +7,7 @@ import { SanitizeError, sanitizeEventsForShare } from "./sanitize.js";
|
|
|
7
7
|
import { buildContentDisposition, isInlineMime } from "../attachments.js";
|
|
8
8
|
import { enrichStoredEventsForDisplay } from "../attachment-labels.js";
|
|
9
9
|
import { log } from "../log.js";
|
|
10
|
+
import { HTTP_STATUS } from "../http-status.js";
|
|
10
11
|
const slog = log.scope("share");
|
|
11
12
|
// In-flight dedup for concurrent POST /share on the same session.
|
|
12
13
|
// First caller does the work; concurrent callers await the same promise.
|
|
@@ -112,7 +113,9 @@ export async function handleShareRoutes(req, res, deps) {
|
|
|
112
113
|
return true;
|
|
113
114
|
}
|
|
114
115
|
if (url === "/s" || url === "/s/") {
|
|
115
|
-
res.writeHead(
|
|
116
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, {
|
|
117
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
118
|
+
});
|
|
116
119
|
res.end("share token required");
|
|
117
120
|
return true;
|
|
118
121
|
}
|
|
@@ -175,7 +178,9 @@ export async function handleShareRoutes(req, res, deps) {
|
|
|
175
178
|
url.startsWith("/api/v1/shared/") ||
|
|
176
179
|
url === "/api/v1/share" ||
|
|
177
180
|
url.startsWith("/api/v1/share/")) {
|
|
178
|
-
res.writeHead(
|
|
181
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, {
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
});
|
|
179
184
|
res.end(JSON.stringify({ error: "not found" }));
|
|
180
185
|
return true;
|
|
181
186
|
}
|
|
@@ -202,12 +207,12 @@ function resolveDisplayName(deps, validated) {
|
|
|
202
207
|
async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
203
208
|
const session = deps.store.getSession(sessionId);
|
|
204
209
|
if (!session) {
|
|
205
|
-
json(res,
|
|
210
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
|
|
206
211
|
return;
|
|
207
212
|
}
|
|
208
213
|
// 409 guard: block while the agent is actively streaming into this session.
|
|
209
214
|
if (deps.sessions?.getBusyKind(sessionId) === "agent") {
|
|
210
|
-
json(res,
|
|
215
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
211
216
|
error: "session busy",
|
|
212
217
|
detail: "此 session 正在接收 agent 输出,请等 agent 输出结束后再分享",
|
|
213
218
|
});
|
|
@@ -221,13 +226,15 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
221
226
|
body = {};
|
|
222
227
|
}
|
|
223
228
|
catch {
|
|
224
|
-
json(res,
|
|
229
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
|
|
225
230
|
return;
|
|
226
231
|
}
|
|
227
232
|
let ttlHours = null;
|
|
228
233
|
if (body.ttl_hours != null) {
|
|
229
234
|
if (!Number.isFinite(body.ttl_hours) || body.ttl_hours < 0) {
|
|
230
|
-
json(res,
|
|
235
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
236
|
+
error: "ttl_hours must be a non-negative number",
|
|
237
|
+
});
|
|
231
238
|
return;
|
|
232
239
|
}
|
|
233
240
|
ttlHours =
|
|
@@ -239,12 +246,12 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
239
246
|
// control/size rejected at entry rather than silently dropped.
|
|
240
247
|
const dnResult = validateLabel(body.display_name, "display_name", 256);
|
|
241
248
|
if (!dnResult.ok) {
|
|
242
|
-
json(res,
|
|
249
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: dnResult.reason });
|
|
243
250
|
return;
|
|
244
251
|
}
|
|
245
252
|
const olResult = validateLabel(body.owner_label, "owner_label", 1024);
|
|
246
253
|
if (!olResult.ok) {
|
|
247
|
-
json(res,
|
|
254
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: olResult.reason });
|
|
248
255
|
return;
|
|
249
256
|
}
|
|
250
257
|
const displayName = resolveDisplayName(deps, dnResult.value);
|
|
@@ -280,7 +287,7 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
280
287
|
if (!existingInflight)
|
|
281
288
|
pendingShareCreates.set(sessionId, inflight);
|
|
282
289
|
const result = await inflight;
|
|
283
|
-
json(res, result.reused ?
|
|
290
|
+
json(res, result.reused ? HTTP_STATUS.OK : HTTP_STATUS.CREATED, {
|
|
284
291
|
token: result.row.token,
|
|
285
292
|
session_id: sessionId,
|
|
286
293
|
snapshot_seq: result.row.share_snapshot_seq,
|
|
@@ -293,7 +300,7 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
293
300
|
}
|
|
294
301
|
catch (err) {
|
|
295
302
|
if (err instanceof SanitizeError) {
|
|
296
|
-
json(res,
|
|
303
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
297
304
|
error: "sanitize rejected",
|
|
298
305
|
event_id: err.event_id,
|
|
299
306
|
rule: err.rule,
|
|
@@ -303,7 +310,10 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
303
310
|
}
|
|
304
311
|
const errorId = randomUUID();
|
|
305
312
|
slog.error("preview_create", { error_id: errorId, error: err });
|
|
306
|
-
json(res,
|
|
313
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
314
|
+
error: "internal error",
|
|
315
|
+
error_id: errorId,
|
|
316
|
+
});
|
|
307
317
|
}
|
|
308
318
|
}
|
|
309
319
|
function runSanitizeGate(events, cwd, internalHosts) {
|
|
@@ -327,25 +337,29 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
327
337
|
const tokenHeader = req.headers["x-share-token"];
|
|
328
338
|
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
|
329
339
|
if (!token) {
|
|
330
|
-
json(res,
|
|
340
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
341
|
+
error: "X-Share-Token header required",
|
|
342
|
+
});
|
|
331
343
|
return;
|
|
332
344
|
}
|
|
333
345
|
const row = deps.store.getShareByToken(token);
|
|
334
346
|
if (!row) {
|
|
335
|
-
json(res,
|
|
347
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
336
348
|
return;
|
|
337
349
|
}
|
|
338
350
|
if (row.session_id !== sessionId) {
|
|
339
|
-
json(res,
|
|
351
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
340
352
|
return;
|
|
341
353
|
}
|
|
342
354
|
if (row.shared_at != null) {
|
|
343
|
-
json(res,
|
|
355
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
356
|
+
error: "share already active (use public viewer)",
|
|
357
|
+
});
|
|
344
358
|
return;
|
|
345
359
|
}
|
|
346
360
|
const session = deps.store.getSession(sessionId);
|
|
347
361
|
if (!session) {
|
|
348
|
-
json(res,
|
|
362
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
|
|
349
363
|
return;
|
|
350
364
|
}
|
|
351
365
|
const allEvents = deps.store
|
|
@@ -366,7 +380,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
366
380
|
});
|
|
367
381
|
// Staleness metadata drives the owner sticky bar text (§2.1 R2-c3).
|
|
368
382
|
const eventsSinceSnapshot = Math.max(0, currentLastSeq - row.share_snapshot_seq);
|
|
369
|
-
json(res,
|
|
383
|
+
json(res, HTTP_STATUS.OK, {
|
|
370
384
|
schema_version: "1.0",
|
|
371
385
|
share: {
|
|
372
386
|
token: row.token,
|
|
@@ -386,7 +400,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
386
400
|
}
|
|
387
401
|
catch (err) {
|
|
388
402
|
if (err instanceof SanitizeError) {
|
|
389
|
-
json(res,
|
|
403
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
390
404
|
error: "sanitize rejected",
|
|
391
405
|
event_id: err.event_id,
|
|
392
406
|
rule: err.rule,
|
|
@@ -395,7 +409,10 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
395
409
|
}
|
|
396
410
|
const errorId = randomUUID();
|
|
397
411
|
slog.error("preview_read", { error_id: errorId, error: err });
|
|
398
|
-
json(res,
|
|
412
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
413
|
+
error: "internal error",
|
|
414
|
+
error_id: errorId,
|
|
415
|
+
});
|
|
399
416
|
}
|
|
400
417
|
}
|
|
401
418
|
/**
|
|
@@ -420,24 +437,24 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
420
437
|
body = {};
|
|
421
438
|
}
|
|
422
439
|
catch {
|
|
423
|
-
json(res,
|
|
440
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
|
|
424
441
|
return;
|
|
425
442
|
}
|
|
426
443
|
if (!body.token || typeof body.token !== "string") {
|
|
427
|
-
json(res,
|
|
444
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
428
445
|
return;
|
|
429
446
|
}
|
|
430
447
|
const row = deps.store.getShareByToken(body.token);
|
|
431
448
|
if (!row) {
|
|
432
|
-
json(res,
|
|
449
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
433
450
|
return;
|
|
434
451
|
}
|
|
435
452
|
if (row.session_id !== sessionId) {
|
|
436
|
-
json(res,
|
|
453
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
437
454
|
return;
|
|
438
455
|
}
|
|
439
456
|
if (row.shared_at != null) {
|
|
440
|
-
json(res,
|
|
457
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
441
458
|
error: "share already active",
|
|
442
459
|
token: row.token,
|
|
443
460
|
shared_at: row.shared_at,
|
|
@@ -451,7 +468,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
451
468
|
if ("display_name" in body) {
|
|
452
469
|
const r = validateLabel(body.display_name, "display_name", 256);
|
|
453
470
|
if (!r.ok) {
|
|
454
|
-
json(res,
|
|
471
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: r.reason });
|
|
455
472
|
return;
|
|
456
473
|
}
|
|
457
474
|
displayName = r.value === "" ? null : r.value;
|
|
@@ -460,7 +477,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
460
477
|
if ("owner_label" in body) {
|
|
461
478
|
const r = validateLabel(body.owner_label, "owner_label", 1024);
|
|
462
479
|
if (!r.ok) {
|
|
463
|
-
json(res,
|
|
480
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: r.reason });
|
|
464
481
|
return;
|
|
465
482
|
}
|
|
466
483
|
ownerLabel = r.value === "" ? null : r.value;
|
|
@@ -473,18 +490,20 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
473
490
|
// Race: concurrent revoke/activate between getShareByToken and activateShare.
|
|
474
491
|
const fresh = deps.store.getShareByToken(body.token);
|
|
475
492
|
if (!fresh) {
|
|
476
|
-
json(res,
|
|
493
|
+
json(res, HTTP_STATUS.GONE, { error: "share revoked" });
|
|
477
494
|
return;
|
|
478
495
|
}
|
|
479
496
|
if (fresh.shared_at != null) {
|
|
480
|
-
json(res,
|
|
497
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
481
498
|
error: "share already active",
|
|
482
499
|
token: fresh.token,
|
|
483
500
|
shared_at: fresh.shared_at,
|
|
484
501
|
});
|
|
485
502
|
return;
|
|
486
503
|
}
|
|
487
|
-
json(res,
|
|
504
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
505
|
+
error: "unexpected activate failure",
|
|
506
|
+
});
|
|
488
507
|
return;
|
|
489
508
|
}
|
|
490
509
|
if (displayName !== undefined && displayName != null) {
|
|
@@ -492,13 +511,15 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
492
511
|
}
|
|
493
512
|
const after = deps.store.getShareByToken(body.token);
|
|
494
513
|
if (!after) {
|
|
495
|
-
json(res,
|
|
514
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
515
|
+
error: "post-activate read failed",
|
|
516
|
+
});
|
|
496
517
|
return;
|
|
497
518
|
}
|
|
498
519
|
const origin = deps.config.viewer_origin && deps.config.viewer_origin !== ""
|
|
499
520
|
? deps.config.viewer_origin.replace(/\/$/, "")
|
|
500
521
|
: "";
|
|
501
|
-
json(res,
|
|
522
|
+
json(res, HTTP_STATUS.OK, {
|
|
502
523
|
token: after.token,
|
|
503
524
|
session_id: sessionId,
|
|
504
525
|
shared_at: after.shared_at,
|
|
@@ -516,7 +537,7 @@ async function handleViewerHtml(res, deps, token) {
|
|
|
516
537
|
if (row?.shared_at == null) {
|
|
517
538
|
// Preview tokens (shared_at IS NULL) MUST NOT resolve publicly.
|
|
518
539
|
const csp = viewerCsp(deps.config.csp_enforce);
|
|
519
|
-
res.writeHead(
|
|
540
|
+
res.writeHead(HTTP_STATUS.GONE, {
|
|
520
541
|
"Content-Type": "text/html; charset=utf-8",
|
|
521
542
|
[csp.name]: csp.value,
|
|
522
543
|
"Cache-Control": "no-store",
|
|
@@ -527,7 +548,7 @@ async function handleViewerHtml(res, deps, token) {
|
|
|
527
548
|
}
|
|
528
549
|
if (isExpired(row)) {
|
|
529
550
|
const csp = viewerCsp(deps.config.csp_enforce);
|
|
530
|
-
res.writeHead(
|
|
551
|
+
res.writeHead(HTTP_STATUS.GONE, {
|
|
531
552
|
"Content-Type": "text/html; charset=utf-8",
|
|
532
553
|
[csp.name]: csp.value,
|
|
533
554
|
"Cache-Control": "no-store",
|
|
@@ -537,13 +558,15 @@ async function handleViewerHtml(res, deps, token) {
|
|
|
537
558
|
return;
|
|
538
559
|
}
|
|
539
560
|
if (!deps.publicDir) {
|
|
540
|
-
json(res,
|
|
561
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
562
|
+
error: "publicDir not configured",
|
|
563
|
+
});
|
|
541
564
|
return;
|
|
542
565
|
}
|
|
543
566
|
try {
|
|
544
567
|
const html = await readFile(join(deps.publicDir, "share-viewer.html"), "utf-8");
|
|
545
568
|
const csp = viewerCsp(deps.config.csp_enforce);
|
|
546
|
-
res.writeHead(
|
|
569
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
547
570
|
"Content-Type": "text/html; charset=utf-8",
|
|
548
571
|
[csp.name]: csp.value,
|
|
549
572
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
@@ -558,7 +581,9 @@ async function handleViewerHtml(res, deps, token) {
|
|
|
558
581
|
catch (err) {
|
|
559
582
|
const errorId = randomUUID();
|
|
560
583
|
slog.error("viewer_html", { error_id: errorId, error: err });
|
|
561
|
-
res.writeHead(
|
|
584
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
585
|
+
"Content-Type": "text/plain",
|
|
586
|
+
});
|
|
562
587
|
res.end(`viewer unavailable (error_id=${errorId})`);
|
|
563
588
|
}
|
|
564
589
|
}
|
|
@@ -569,11 +594,11 @@ async function handleViewerHtml(res, deps, token) {
|
|
|
569
594
|
async function handleSharedEvents(res, deps, token) {
|
|
570
595
|
const row = deps.store.getShareByToken(token);
|
|
571
596
|
if (row?.shared_at == null) {
|
|
572
|
-
json(res,
|
|
597
|
+
json(res, HTTP_STATUS.GONE, { error: "share revoked or not found" });
|
|
573
598
|
return;
|
|
574
599
|
}
|
|
575
600
|
if (isExpired(row)) {
|
|
576
|
-
json(res,
|
|
601
|
+
json(res, HTTP_STATUS.GONE, { error: "share expired" });
|
|
577
602
|
return;
|
|
578
603
|
}
|
|
579
604
|
// Public viewer must keep working after the owner deletes the source
|
|
@@ -581,7 +606,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
581
606
|
// them (Store.deleteSession soft-deletes when shares exist).
|
|
582
607
|
const session = deps.store.getSessionIncludingDeleted(row.session_id);
|
|
583
608
|
if (!session) {
|
|
584
|
-
json(res,
|
|
609
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session vanished" });
|
|
585
610
|
return;
|
|
586
611
|
}
|
|
587
612
|
const allEvents = deps.store
|
|
@@ -598,7 +623,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
598
623
|
internalHosts: deps.config.internal_hosts,
|
|
599
624
|
});
|
|
600
625
|
// Public response: DOES NOT expose session_id. Only title + display_name + meta.
|
|
601
|
-
res.writeHead(
|
|
626
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
602
627
|
"Content-Type": "application/json",
|
|
603
628
|
"Cache-Control": "no-store",
|
|
604
629
|
"X-Content-Type-Options": "nosniff",
|
|
@@ -626,12 +651,15 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
626
651
|
rule: err.rule,
|
|
627
652
|
event_id: err.event_id,
|
|
628
653
|
});
|
|
629
|
-
json(res,
|
|
654
|
+
json(res, HTTP_STATUS.GONE, { error: "share unavailable" });
|
|
630
655
|
return;
|
|
631
656
|
}
|
|
632
657
|
const errorId = randomUUID();
|
|
633
658
|
slog.error("shared_events", { error_id: errorId, error: err });
|
|
634
|
-
json(res,
|
|
659
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
660
|
+
error: "internal error",
|
|
661
|
+
error_id: errorId,
|
|
662
|
+
});
|
|
635
663
|
}
|
|
636
664
|
}
|
|
637
665
|
/**
|
|
@@ -644,7 +672,9 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
644
672
|
*/
|
|
645
673
|
async function handleViewerAsset(res, deps, file) {
|
|
646
674
|
if (!deps.publicDir) {
|
|
647
|
-
res.writeHead(
|
|
675
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
676
|
+
"Content-Type": "text/plain",
|
|
677
|
+
});
|
|
648
678
|
res.end("publicDir not configured");
|
|
649
679
|
return;
|
|
650
680
|
}
|
|
@@ -654,7 +684,7 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
654
684
|
const cssMatch = /^(styles|share-viewer)(?:\.[A-Za-z0-9_-]+)?\.css$/.test(file);
|
|
655
685
|
const jsMatch = /^(viewer|chunk)(?:\.[A-Za-z0-9_-]+)?\.js$/.test(file);
|
|
656
686
|
if (!cssMatch && !jsMatch) {
|
|
657
|
-
res.writeHead(
|
|
687
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
|
|
658
688
|
res.end("not found");
|
|
659
689
|
return;
|
|
660
690
|
}
|
|
@@ -672,7 +702,7 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
672
702
|
: "text/css; charset=utf-8";
|
|
673
703
|
try {
|
|
674
704
|
const buf = await readFile(filePath);
|
|
675
|
-
res.writeHead(
|
|
705
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
676
706
|
"Content-Type": contentType,
|
|
677
707
|
"Cache-Control": cacheControl,
|
|
678
708
|
"X-Content-Type-Options": "nosniff",
|
|
@@ -681,7 +711,7 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
681
711
|
res.end(buf);
|
|
682
712
|
}
|
|
683
713
|
catch {
|
|
684
|
-
res.writeHead(
|
|
714
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
|
|
685
715
|
res.end("not found");
|
|
686
716
|
}
|
|
687
717
|
}
|
|
@@ -692,28 +722,30 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
692
722
|
*/
|
|
693
723
|
async function handleViewerImage(res, deps, token, file) {
|
|
694
724
|
if (!deps.dataDir) {
|
|
695
|
-
json(res,
|
|
725
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
726
|
+
error: "dataDir not configured",
|
|
727
|
+
});
|
|
696
728
|
return;
|
|
697
729
|
}
|
|
698
730
|
const row = deps.store.getShareByToken(token);
|
|
699
731
|
if (row?.shared_at == null) {
|
|
700
|
-
json(res,
|
|
732
|
+
json(res, HTTP_STATUS.GONE, { error: "share unavailable" });
|
|
701
733
|
return;
|
|
702
734
|
}
|
|
703
735
|
if (isExpired(row)) {
|
|
704
|
-
json(res,
|
|
736
|
+
json(res, HTTP_STATUS.GONE, { error: "share expired" });
|
|
705
737
|
return;
|
|
706
738
|
}
|
|
707
739
|
// Only allow simple filenames — reject any path separators / dotfiles / traversal.
|
|
708
740
|
if (!/^[A-Za-z0-9._-]+$/.test(file) || file.startsWith(".")) {
|
|
709
|
-
json(res,
|
|
741
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
|
|
710
742
|
return;
|
|
711
743
|
}
|
|
712
744
|
const sessionRoot = join(deps.dataDir, "sessions", row.session_id, "attachments");
|
|
713
745
|
const filePath = join(sessionRoot, file);
|
|
714
746
|
// Final realpath-style guard: must stay under <dataDir>/sessions/<sid>/attachments.
|
|
715
747
|
if (!filePath.startsWith(sessionRoot + "/") && filePath !== sessionRoot) {
|
|
716
|
-
res.writeHead(
|
|
748
|
+
res.writeHead(HTTP_STATUS.FORBIDDEN);
|
|
717
749
|
res.end("Forbidden");
|
|
718
750
|
return;
|
|
719
751
|
}
|
|
@@ -740,11 +772,11 @@ async function handleViewerImage(res, deps, token, file) {
|
|
|
740
772
|
const disposition = isInlineMime(mime) ? "inline" : "attachment";
|
|
741
773
|
headers["Content-Disposition"] = buildContentDisposition(disposition, att.name);
|
|
742
774
|
}
|
|
743
|
-
res.writeHead(
|
|
775
|
+
res.writeHead(HTTP_STATUS.OK, headers);
|
|
744
776
|
res.end(buf);
|
|
745
777
|
}
|
|
746
778
|
catch {
|
|
747
|
-
res.writeHead(
|
|
779
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
|
|
748
780
|
res.end("Not found");
|
|
749
781
|
}
|
|
750
782
|
}
|
|
@@ -810,11 +842,11 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
810
842
|
body = {};
|
|
811
843
|
}
|
|
812
844
|
catch {
|
|
813
|
-
json(res,
|
|
845
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
|
|
814
846
|
return;
|
|
815
847
|
}
|
|
816
848
|
if (!body.token || typeof body.token !== "string") {
|
|
817
|
-
json(res,
|
|
849
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
818
850
|
return;
|
|
819
851
|
}
|
|
820
852
|
const row = deps.store.getShareByToken(body.token);
|
|
@@ -822,7 +854,7 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
822
854
|
// Idempotent DELETE: row already gone (revoked or never existed).
|
|
823
855
|
// We can't verify session ownership without a row, but the token
|
|
824
856
|
// is opaque/random so leaking "revoked or never existed" is fine.
|
|
825
|
-
json(res,
|
|
857
|
+
json(res, HTTP_STATUS.OK, {
|
|
826
858
|
ok: true,
|
|
827
859
|
token: body.token,
|
|
828
860
|
revoked: false,
|
|
@@ -831,7 +863,7 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
831
863
|
return;
|
|
832
864
|
}
|
|
833
865
|
if (row.session_id !== sessionId) {
|
|
834
|
-
json(res,
|
|
866
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
835
867
|
return;
|
|
836
868
|
}
|
|
837
869
|
const revoked = deps.store.revokeShare(body.token);
|
|
@@ -847,7 +879,7 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
847
879
|
}).catch(() => { });
|
|
848
880
|
}
|
|
849
881
|
}
|
|
850
|
-
json(res,
|
|
882
|
+
json(res, HTTP_STATUS.OK, {
|
|
851
883
|
ok: true,
|
|
852
884
|
token: body.token,
|
|
853
885
|
revoked,
|
|
@@ -871,27 +903,27 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
871
903
|
body = {};
|
|
872
904
|
}
|
|
873
905
|
catch {
|
|
874
|
-
json(res,
|
|
906
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
|
|
875
907
|
return;
|
|
876
908
|
}
|
|
877
909
|
if (!body.token || typeof body.token !== "string") {
|
|
878
|
-
json(res,
|
|
910
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
879
911
|
return;
|
|
880
912
|
}
|
|
881
913
|
const row = deps.store.getShareByToken(body.token);
|
|
882
914
|
if (!row) {
|
|
883
|
-
json(res,
|
|
915
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
884
916
|
return;
|
|
885
917
|
}
|
|
886
918
|
if (row.session_id !== sessionId) {
|
|
887
|
-
json(res,
|
|
919
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
888
920
|
return;
|
|
889
921
|
}
|
|
890
922
|
let ownerLabel = undefined;
|
|
891
923
|
if ("owner_label" in body) {
|
|
892
924
|
const v = validateLabel(body.owner_label, "owner_label", 1024);
|
|
893
925
|
if (!v.ok) {
|
|
894
|
-
json(res,
|
|
926
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
|
|
895
927
|
return;
|
|
896
928
|
}
|
|
897
929
|
ownerLabel = v.value === "" ? null : v.value;
|
|
@@ -900,7 +932,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
900
932
|
if ("display_name" in body) {
|
|
901
933
|
const v = validateLabel(body.display_name, "display_name", 256);
|
|
902
934
|
if (!v.ok) {
|
|
903
|
-
json(res,
|
|
935
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
|
|
904
936
|
return;
|
|
905
937
|
}
|
|
906
938
|
displayName = v.value === "" ? null : v.value;
|
|
@@ -911,10 +943,12 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
911
943
|
deps.store.updateShareDisplayName(body.token, displayName);
|
|
912
944
|
const after = deps.store.getShareByToken(body.token);
|
|
913
945
|
if (!after) {
|
|
914
|
-
json(res,
|
|
946
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
947
|
+
error: "post-patch read failed",
|
|
948
|
+
});
|
|
915
949
|
return;
|
|
916
950
|
}
|
|
917
|
-
json(res,
|
|
951
|
+
json(res, HTTP_STATUS.OK, {
|
|
918
952
|
token: after.token,
|
|
919
953
|
session_id: sessionId,
|
|
920
954
|
owner_label: after.owner_label,
|
|
@@ -928,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
928
962
|
*/
|
|
929
963
|
async function handleOwnerList(req, res, deps) {
|
|
930
964
|
const rows = deps.store.listOwnerShares();
|
|
931
|
-
json(res,
|
|
965
|
+
json(res, HTTP_STATUS.OK, { shares: rows });
|
|
932
966
|
}
|
|
933
967
|
/**
|
|
934
968
|
* GET /api/v1/share/by — read the owner's default display_name (used by
|
|
@@ -937,7 +971,7 @@ async function handleOwnerList(req, res, deps) {
|
|
|
937
971
|
*/
|
|
938
972
|
async function handleByGet(req, res, deps) {
|
|
939
973
|
const value = deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
|
|
940
|
-
json(res,
|
|
974
|
+
json(res, HTTP_STATUS.OK, { value });
|
|
941
975
|
}
|
|
942
976
|
/**
|
|
943
977
|
* PUT /api/v1/share/by — set or clear the owner's default display_name.
|
|
@@ -953,20 +987,20 @@ async function handleByPut(req, res, deps) {
|
|
|
953
987
|
body = {};
|
|
954
988
|
}
|
|
955
989
|
catch {
|
|
956
|
-
json(res,
|
|
990
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
|
|
957
991
|
return;
|
|
958
992
|
}
|
|
959
993
|
const v = validateLabel(body.value, "value", 256);
|
|
960
994
|
if (!v.ok) {
|
|
961
|
-
json(res,
|
|
995
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
|
|
962
996
|
return;
|
|
963
997
|
}
|
|
964
998
|
if (v.value === "") {
|
|
965
999
|
deps.store.clearOwnerPref(DEFAULT_DISPLAY_NAME_KEY);
|
|
966
|
-
json(res,
|
|
1000
|
+
json(res, HTTP_STATUS.OK, { value: null });
|
|
967
1001
|
}
|
|
968
1002
|
else {
|
|
969
1003
|
deps.store.setOwnerPref(DEFAULT_DISPLAY_NAME_KEY, v.value);
|
|
970
|
-
json(res,
|
|
1004
|
+
json(res, HTTP_STATUS.OK, { value: v.value });
|
|
971
1005
|
}
|
|
972
1006
|
}
|
package/lib/share/sanitize.js
CHANGED
package/lib/sse-manager.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { reSignAttachmentUrlsInJson } from "./auth.js";
|
|
3
3
|
import { enrichEventForDisplay } from "./attachment-labels.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
const slog = log.scope("sse");
|
|
4
6
|
/**
|
|
5
7
|
* SSE heartbeat frame — a NAMED event so the frontend can hook
|
|
6
8
|
* `es.addEventListener("heartbeat", ...)` and refresh its per-session
|
|
@@ -69,7 +71,7 @@ export class SseManager {
|
|
|
69
71
|
catch {
|
|
70
72
|
/* already torn down */
|
|
71
73
|
}
|
|
72
|
-
this.remove(client.id);
|
|
74
|
+
this.remove(client.id, "token-revoked");
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
77
|
client.res.write(SSE_HEARTBEAT_FRAME);
|
|
@@ -91,8 +93,13 @@ export class SseManager {
|
|
|
91
93
|
/** Register a new SSE client connection. */
|
|
92
94
|
add(client) {
|
|
93
95
|
this.clients.set(client.id, client);
|
|
96
|
+
slog.info("connected", {
|
|
97
|
+
clientId: client.id,
|
|
98
|
+
sessionId: client.sessionId ?? "*",
|
|
99
|
+
clients: this.clients.size,
|
|
100
|
+
});
|
|
94
101
|
client.res.on("close", () => {
|
|
95
|
-
this.remove(client.id);
|
|
102
|
+
this.remove(client.id, "closed");
|
|
96
103
|
});
|
|
97
104
|
}
|
|
98
105
|
/** Write a single heartbeat frame to the given client. Used right after
|
|
@@ -109,9 +116,16 @@ export class SseManager {
|
|
|
109
116
|
// socket already dead; res.on("close") will clean up
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
/** Remove a client by ID.
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
/** Remove a client by ID. `reason` is recorded so an operator can tell an
|
|
120
|
+
* ordinary disconnect apart from a write failure or a revoked token. */
|
|
121
|
+
remove(id, reason = "closed") {
|
|
122
|
+
if (this.clients.delete(id)) {
|
|
123
|
+
slog.info("disconnected", {
|
|
124
|
+
clientId: id,
|
|
125
|
+
reason,
|
|
126
|
+
clients: this.clients.size,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
115
129
|
this.onRemoveCallback?.(id);
|
|
116
130
|
}
|
|
117
131
|
/** Send an SSE event to a single client. */
|
|
@@ -138,10 +152,11 @@ export class SseManager {
|
|
|
138
152
|
try {
|
|
139
153
|
client.res.write(msg);
|
|
140
154
|
}
|
|
141
|
-
catch {
|
|
155
|
+
catch (err) {
|
|
142
156
|
// Socket torn down between writableEnded check and write.
|
|
143
157
|
// Drop the client so we stop writing to it on every broadcast.
|
|
144
|
-
|
|
158
|
+
slog.warn("write failed", { clientId: client.id, err: String(err) });
|
|
159
|
+
this.remove(client.id, "write-failed");
|
|
145
160
|
}
|
|
146
161
|
}
|
|
147
162
|
/**
|
|
@@ -159,6 +174,15 @@ export class SseManager {
|
|
|
159
174
|
this.sendEvent(client, event);
|
|
160
175
|
}
|
|
161
176
|
}
|
|
177
|
+
/** Broadcast global application state regardless of a client's session filter. */
|
|
178
|
+
broadcastGlobal(event) {
|
|
179
|
+
const snapshot = [...this.clients.values()];
|
|
180
|
+
for (const client of snapshot) {
|
|
181
|
+
if (client.res.writableEnded)
|
|
182
|
+
continue;
|
|
183
|
+
this.sendEvent(client, event);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
162
186
|
/** Get count of connected clients. */
|
|
163
187
|
get size() {
|
|
164
188
|
return this.clients.size;
|