dsh-claude-slides 2.4.1 → 2.6.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 +1 -1
- package/docs/DETAIL.md +1 -1
- package/docs/DETAIL.zh.md +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +30 -6
- package/package.json +1 -1
- package/skills/claude-slides/SKILL.md +26 -3
- package/skills/claude-slides/scripts/artifact.mjs +318 -79
- package/skills/claude-slides/scripts/editor/assets/{index-RJQpopVD.js → index-ExkfOJag.js} +277 -172
- package/skills/claude-slides/scripts/editor/assets/{woff2-decoder-BtKfcPFf.js → woff2-decoder-DHpUx46X.js} +1 -1
- package/skills/claude-slides/scripts/editor/index.html +1 -1
- package/skills/claude-slides/scripts/editor/node/compiler.cjs +149 -9
- package/skills/claude-slides/scripts/editor/share-ui.js +178 -0
- package/skills/claude-slides/scripts/server.mjs +2076 -125
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// claude-slides: local editor server (prebuilt; do not edit).
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs8 from "node:fs";
|
|
5
|
+
import path7 from "node:path";
|
|
6
6
|
|
|
7
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
-
import
|
|
7
|
+
import { createHash as createHash3, randomBytes as randomBytes2, randomUUID } from "node:crypto";
|
|
8
|
+
import fs7 from "node:fs";
|
|
9
9
|
import fsp2 from "node:fs/promises";
|
|
10
10
|
import http from "node:http";
|
|
11
|
-
import
|
|
11
|
+
import path6 from "node:path";
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
13
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
@@ -18,6 +18,9 @@ import os from "node:os";
|
|
|
18
18
|
var COMMENTS_FILE = "comments.json";
|
|
19
19
|
var COMMENTS_VERSION = 1;
|
|
20
20
|
var LIMITS = { text: 1e4, label: 1e3, detail: 4e3, anchor: 2048, section: 200, name: 100 };
|
|
21
|
+
function sameAuthor(a, b) {
|
|
22
|
+
return a.kind === "user" && b.kind === "user" && (a.principal ?? "") === (b.principal ?? "");
|
|
23
|
+
}
|
|
21
24
|
var CommentsError = class extends Error {
|
|
22
25
|
code;
|
|
23
26
|
status;
|
|
@@ -88,7 +91,10 @@ function parseDoc(text) {
|
|
|
88
91
|
const threads = data.threads.filter(
|
|
89
92
|
(t) => isRecord(t) && typeof t.id === "string" && typeof t.anchor === "string" && typeof t.label === "string" && (t.status === "open" || t.status === "resolved") && Array.isArray(t.messages) && t.messages.every((m) => isRecord(m) && typeof m.text === "string" && isRecord(m.author) && (m.author.kind === "user" || m.author.kind === "agent"))
|
|
90
93
|
);
|
|
91
|
-
for (const t of threads)
|
|
94
|
+
for (const t of threads) {
|
|
95
|
+
if (!isRecord(t.location)) t.location = {};
|
|
96
|
+
for (const m of t.messages) if (m.author.principal !== void 0 && typeof m.author.principal !== "string") delete m.author.principal;
|
|
97
|
+
}
|
|
92
98
|
return { version: COMMENTS_VERSION, threads };
|
|
93
99
|
}
|
|
94
100
|
var formatDoc = (doc) => `${JSON.stringify(doc, null, 2)}
|
|
@@ -127,10 +133,10 @@ function addMessage(thread, author, text, now) {
|
|
|
127
133
|
}
|
|
128
134
|
return message;
|
|
129
135
|
}
|
|
130
|
-
function editMessage(thread, messageId, text, now) {
|
|
136
|
+
function editMessage(thread, messageId, text, now, by) {
|
|
131
137
|
const message = thread.messages.find((m) => m.id === messageId);
|
|
132
138
|
if (!message) throw new CommentsError("MESSAGE_NOT_FOUND", `No message ${messageId} in ${thread.id}.`, 404);
|
|
133
|
-
if (message.author.kind !== "user") throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
|
|
139
|
+
if (message.author.kind !== "user" || by && !sameAuthor(message.author, by)) throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
|
|
134
140
|
if (message.text !== text) {
|
|
135
141
|
message.text = text;
|
|
136
142
|
message.editedAt = now;
|
|
@@ -275,6 +281,7 @@ async function updateComments(root, change) {
|
|
|
275
281
|
var AGENT_GRACE_MS = 6e4;
|
|
276
282
|
var AGENT_BUSY_MS = 30 * 6e4;
|
|
277
283
|
var REQUEST_TTL_MS = 30 * 6e4;
|
|
284
|
+
var REQUESTS_KEPT = 32;
|
|
278
285
|
var WAIT_DEFAULT_S = 300;
|
|
279
286
|
var WAIT_MAX_S = 3600;
|
|
280
287
|
var PRESENCE_CHECK_MS = 5e3;
|
|
@@ -332,12 +339,14 @@ function defaultUserName() {
|
|
|
332
339
|
function createCommentsService(options) {
|
|
333
340
|
const { root, log, agentHook } = options;
|
|
334
341
|
const userName = (options.userName ?? defaultUserName()).slice(0, 100);
|
|
335
|
-
const
|
|
342
|
+
const owner = { author: { kind: "user", name: userName }, canComment: true, canSend: true, admin: true };
|
|
343
|
+
const viewerOf = options.viewer ?? (() => owner);
|
|
336
344
|
const allowWrite = options.allowWrite ?? (() => true);
|
|
337
|
-
const clients = /* @__PURE__ */ new
|
|
345
|
+
const clients = /* @__PURE__ */ new Map();
|
|
338
346
|
const waiters = [];
|
|
339
347
|
const hookRunning = /* @__PURE__ */ new Set();
|
|
340
348
|
const requests = [];
|
|
349
|
+
const requestFates = /* @__PURE__ */ new Map();
|
|
341
350
|
let lastDoc = emptyDoc();
|
|
342
351
|
let lastWaitEnded = 0;
|
|
343
352
|
let lastSent = "";
|
|
@@ -350,13 +359,23 @@ function createCommentsService(options) {
|
|
|
350
359
|
const listening = waiters.length > 0;
|
|
351
360
|
const busy = lastDoc.threads.some(
|
|
352
361
|
(t) => t.status === "open" && t.agent?.status === "delivered" && at - Date.parse(t.agent.deliveredAt ?? t.agent.sentAt) < AGENT_BUSY_MS
|
|
353
|
-
);
|
|
362
|
+
) || [...requestFates.values()].some((f) => f.state === "working" && at - f.at < AGENT_BUSY_MS);
|
|
354
363
|
const hook = Boolean(agentHook);
|
|
355
364
|
return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
|
|
356
365
|
}
|
|
357
|
-
|
|
366
|
+
const meOf = (viewer) => ({
|
|
367
|
+
name: viewer.author.name,
|
|
368
|
+
...viewer.author.principal ? { principal: viewer.author.principal } : {},
|
|
369
|
+
canComment: viewer.canComment,
|
|
370
|
+
canSend: viewer.canSend,
|
|
371
|
+
admin: viewer.admin
|
|
372
|
+
});
|
|
373
|
+
async function shared() {
|
|
358
374
|
lastDoc = await readComments(root);
|
|
359
|
-
return { threads: lastDoc.threads, agent: presence()
|
|
375
|
+
return { threads: lastDoc.threads, agent: presence() };
|
|
376
|
+
}
|
|
377
|
+
async function snapshot(viewer) {
|
|
378
|
+
return { ...await shared(), me: meOf(viewer) };
|
|
360
379
|
}
|
|
361
380
|
const frame = (snap) => `event: comments
|
|
362
381
|
data: ${JSON.stringify(snap)}
|
|
@@ -365,18 +384,18 @@ data: ${JSON.stringify(snap)}
|
|
|
365
384
|
function broadcast() {
|
|
366
385
|
queue = queue.then(async () => {
|
|
367
386
|
if (closed) return;
|
|
368
|
-
let
|
|
387
|
+
let base;
|
|
369
388
|
try {
|
|
370
|
-
|
|
389
|
+
base = await shared();
|
|
371
390
|
} catch (error) {
|
|
372
391
|
log(`comments: reading ${COMMENTS_FILE} failed: ${error.message}`);
|
|
373
392
|
return;
|
|
374
393
|
}
|
|
375
|
-
const text =
|
|
376
|
-
lastPresence = JSON.stringify(
|
|
394
|
+
const text = JSON.stringify(base);
|
|
395
|
+
lastPresence = JSON.stringify(base.agent);
|
|
377
396
|
if (text === lastSent) return;
|
|
378
397
|
lastSent = text;
|
|
379
|
-
for (const client of clients) client.write(
|
|
398
|
+
for (const [client, viewer] of clients) client.write(frame({ ...base, me: meOf(viewer) }));
|
|
380
399
|
});
|
|
381
400
|
return queue;
|
|
382
401
|
}
|
|
@@ -389,10 +408,16 @@ data: ${JSON.stringify(snap)}
|
|
|
389
408
|
if (!waiter.response.writableEnded) sendJson(waiter.response, 200, body);
|
|
390
409
|
lastWaitEnded = Date.now();
|
|
391
410
|
}
|
|
411
|
+
function setRequestState(id, state) {
|
|
412
|
+
requestFates.delete(id);
|
|
413
|
+
requestFates.set(id, { state, at: Date.now() });
|
|
414
|
+
while (requestFates.size > REQUESTS_KEPT) requestFates.delete(requestFates.keys().next().value);
|
|
415
|
+
}
|
|
392
416
|
function dropStaleRequests() {
|
|
393
417
|
const oldest = Date.now() - REQUEST_TTL_MS;
|
|
394
418
|
while (requests.length > 0 && Date.parse(requests[0].sentAt) < oldest) {
|
|
395
419
|
const dropped = requests.shift();
|
|
420
|
+
setRequestState(dropped.id, "over");
|
|
396
421
|
log(`comments: dropped request ${dropped.id}: no agent took it in ${REQUEST_TTL_MS / 6e4} minutes`);
|
|
397
422
|
}
|
|
398
423
|
}
|
|
@@ -419,6 +444,7 @@ data: ${JSON.stringify(snap)}
|
|
|
419
444
|
return;
|
|
420
445
|
}
|
|
421
446
|
finishWaiter(waiter, { threads: views, ...handed.length > 0 ? { requests: handed } : {} });
|
|
447
|
+
for (const request of handed) setRequestState(request.id, "working");
|
|
422
448
|
log(`comments: sent to the agent (wait): ${[...views, ...handed].map((v) => v.id).join(", ")}`);
|
|
423
449
|
await broadcast();
|
|
424
450
|
return;
|
|
@@ -466,6 +492,7 @@ data: ${JSON.stringify(snap)}
|
|
|
466
492
|
}
|
|
467
493
|
for (const request of requests.splice(0)) {
|
|
468
494
|
hookRunning.add(request.id);
|
|
495
|
+
setRequestState(request.id, "working");
|
|
469
496
|
const child = spawn(command, {
|
|
470
497
|
shell: true,
|
|
471
498
|
cwd: root,
|
|
@@ -486,6 +513,7 @@ data: ${JSON.stringify(snap)}
|
|
|
486
513
|
settled = true;
|
|
487
514
|
hookRunning.delete(request.id);
|
|
488
515
|
requests.unshift(request);
|
|
516
|
+
setRequestState(request.id, "queued");
|
|
489
517
|
log(`comments: the agent hook ${why} for ${request.id}; the request stays waiting${stderr ? `: ${stderr.trim()}` : ""}`);
|
|
490
518
|
};
|
|
491
519
|
child.on("error", (error) => failed(`failed to start (${error.message})`));
|
|
@@ -532,31 +560,46 @@ data: ${JSON.stringify(snap)}
|
|
|
532
560
|
await broadcast();
|
|
533
561
|
if (sent) await deliver();
|
|
534
562
|
}
|
|
535
|
-
|
|
563
|
+
function need(allowed, message) {
|
|
564
|
+
if (!allowed) throw new CommentsError("NOT_ALLOWED", message, 403);
|
|
565
|
+
}
|
|
566
|
+
const ONLY_OWNER_SENDS = "Only the owner can send comments to the agent.";
|
|
567
|
+
async function create(request, response, viewer) {
|
|
536
568
|
const input = readNewThread(await readJson(request));
|
|
537
569
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {anchor, label, text} with a non-empty text");
|
|
570
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
538
571
|
let location = {};
|
|
539
572
|
try {
|
|
540
573
|
location = await options.locate?.(input) ?? {};
|
|
541
574
|
} catch (error) {
|
|
542
575
|
log(`comments: could not resolve where a comment points: ${error.message}`);
|
|
543
576
|
}
|
|
544
|
-
const created = await updateComments(root, (doc) => addThread(doc, input, location,
|
|
577
|
+
const created = await updateComments(root, (doc) => addThread(doc, input, location, viewer.author, now()));
|
|
545
578
|
log(`comments: ${created.id} added on ${created.label}${location.file ? ` (${location.file})` : ""}${input.send ? ", sent to the agent" : ""}`);
|
|
546
579
|
sendJson(response, 201, { thread: created });
|
|
547
580
|
await afterChange(Boolean(input.send));
|
|
548
581
|
}
|
|
549
|
-
|
|
550
|
-
const input = readAgentRequest(await readJson(request));
|
|
551
|
-
if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
|
|
582
|
+
function enqueue(input) {
|
|
552
583
|
if (!presence().available) throw new CommentsError("AGENT_UNAVAILABLE", "no agent is waiting: copy the request for the chat instead", 409);
|
|
553
584
|
const queued = { id: newId("r"), ...input, sentAt: now() };
|
|
554
585
|
requests.push(queued);
|
|
586
|
+
setRequestState(queued.id, "queued");
|
|
555
587
|
log(`comments: request ${queued.id}${input.label ? ` (${input.label})` : ""} for the agent`);
|
|
588
|
+
return queued;
|
|
589
|
+
}
|
|
590
|
+
async function ask(request, response) {
|
|
591
|
+
const input = readAgentRequest(await readJson(request));
|
|
592
|
+
if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
|
|
593
|
+
const queued = enqueue(input);
|
|
556
594
|
sendJson(response, 202, { request: queued });
|
|
557
595
|
await deliver();
|
|
558
596
|
}
|
|
559
|
-
async function
|
|
597
|
+
async function sendRequest(input) {
|
|
598
|
+
const queued = enqueue(input);
|
|
599
|
+
await deliver();
|
|
600
|
+
return queued;
|
|
601
|
+
}
|
|
602
|
+
async function events(request, response, viewer) {
|
|
560
603
|
response.writeHead(200, {
|
|
561
604
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
562
605
|
"Cache-Control": "no-store",
|
|
@@ -564,8 +607,8 @@ data: ${JSON.stringify(snap)}
|
|
|
564
607
|
"X-Accel-Buffering": "no"
|
|
565
608
|
});
|
|
566
609
|
response.write(": connected\n\n");
|
|
567
|
-
response.write(frame(await snapshot()));
|
|
568
|
-
clients.
|
|
610
|
+
response.write(frame(await snapshot(viewer)));
|
|
611
|
+
clients.set(response, viewer);
|
|
569
612
|
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS);
|
|
570
613
|
keepAlive.unref?.();
|
|
571
614
|
response.on("close", () => {
|
|
@@ -578,6 +621,7 @@ data: ${JSON.stringify(snap)}
|
|
|
578
621
|
const seconds = Number.isFinite(asked) && asked > 0 ? Math.min(asked, WAIT_MAX_S) : WAIT_DEFAULT_S;
|
|
579
622
|
const waiter = { response, done: false };
|
|
580
623
|
waiters.push(waiter);
|
|
624
|
+
for (const [id, fate] of requestFates) if (fate.state === "working") setRequestState(id, "over");
|
|
581
625
|
waiter.timer = setTimeout(() => {
|
|
582
626
|
finishWaiter(waiter, { threads: [] });
|
|
583
627
|
void broadcast();
|
|
@@ -601,14 +645,26 @@ data: ${JSON.stringify(snap)}
|
|
|
601
645
|
request.resume();
|
|
602
646
|
throw new CommentsError("NOT_ALLOWED", "cross-origin request", 403);
|
|
603
647
|
}
|
|
648
|
+
const viewer = viewerOf(request);
|
|
649
|
+
if (!viewer) {
|
|
650
|
+
request.resume();
|
|
651
|
+
throw new CommentsError("NOT_ALLOWED", "not shared with you", 403);
|
|
652
|
+
}
|
|
653
|
+
const commenting = !reading && parts[0] !== "agent" && parts[1] !== "send";
|
|
654
|
+
if (commenting && !viewer.canComment) {
|
|
655
|
+
request.resume();
|
|
656
|
+
throw new CommentsError("NOT_ALLOWED", "You can read the comments here, not add or change them.", 403);
|
|
657
|
+
}
|
|
604
658
|
if (parts.length === 0) {
|
|
605
|
-
if (reading) return sendJson(response, 200, await snapshot());
|
|
606
|
-
if (method === "POST") return await create(request, response);
|
|
659
|
+
if (reading) return sendJson(response, 200, await snapshot(viewer));
|
|
660
|
+
if (method === "POST") return await create(request, response, viewer);
|
|
607
661
|
} else if (parts.length === 1 && parts[0] === "events") {
|
|
608
|
-
if (reading) return await events(request, response);
|
|
662
|
+
if (reading) return await events(request, response, viewer);
|
|
609
663
|
} else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "wait") {
|
|
664
|
+
need(viewer.canSend, "Only the owner's agent waits for comments.");
|
|
610
665
|
if (reading) return await wait(request, response, url);
|
|
611
666
|
} else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "requests") {
|
|
667
|
+
need(viewer.canSend, ONLY_OWNER_SENDS);
|
|
612
668
|
if (method === "POST") return await ask(request, response);
|
|
613
669
|
} else if (parts.length === 1) {
|
|
614
670
|
const id = parts[0];
|
|
@@ -627,6 +683,8 @@ data: ${JSON.stringify(snap)}
|
|
|
627
683
|
if (method === "DELETE") {
|
|
628
684
|
await updateComments(root, (doc) => {
|
|
629
685
|
const found = findThread(doc, id);
|
|
686
|
+
const first = found.messages[0];
|
|
687
|
+
need(viewer.admin || first !== void 0 && sameAuthor(first.author, viewer.author), "Only the person who started this comment, or the owner, can delete it.");
|
|
630
688
|
doc.threads.splice(doc.threads.indexOf(found), 1);
|
|
631
689
|
});
|
|
632
690
|
log(`comments: ${id} deleted`);
|
|
@@ -638,9 +696,10 @@ data: ${JSON.stringify(snap)}
|
|
|
638
696
|
const id = parts[0];
|
|
639
697
|
const input = readReply(await readJson(request));
|
|
640
698
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
|
|
699
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
641
700
|
const t = await updateComments(root, (doc) => {
|
|
642
701
|
const found = findThread(doc, id);
|
|
643
|
-
addMessage(found,
|
|
702
|
+
addMessage(found, viewer.author, input.text, now());
|
|
644
703
|
if (input.send) sendToAgent(found, now());
|
|
645
704
|
return found;
|
|
646
705
|
});
|
|
@@ -654,9 +713,10 @@ data: ${JSON.stringify(snap)}
|
|
|
654
713
|
const messageId = parts[2];
|
|
655
714
|
const input = readEdit(await readJson(request));
|
|
656
715
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
|
|
716
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
657
717
|
const t = await updateComments(root, (doc) => {
|
|
658
718
|
const found = findThread(doc, id);
|
|
659
|
-
editMessage(found, messageId, input.text, now());
|
|
719
|
+
editMessage(found, messageId, input.text, now(), viewer.author);
|
|
660
720
|
if (input.send && canSendToAgent(found)) sendToAgent(found, now());
|
|
661
721
|
return found;
|
|
662
722
|
});
|
|
@@ -668,6 +728,7 @@ data: ${JSON.stringify(snap)}
|
|
|
668
728
|
if (method === "POST") {
|
|
669
729
|
const id = parts[0];
|
|
670
730
|
request.resume();
|
|
731
|
+
need(viewer.canSend, ONLY_OWNER_SENDS);
|
|
671
732
|
const t = await updateComments(root, (doc) => {
|
|
672
733
|
const found = findThread(doc, id);
|
|
673
734
|
if (!canSendToAgent(found)) throw new CommentsError("NOTHING_TO_SEND", "the agent already has everything in this comment", 409);
|
|
@@ -696,13 +757,20 @@ data: ${JSON.stringify(snap)}
|
|
|
696
757
|
clearTimeout(debounce);
|
|
697
758
|
watcher?.close();
|
|
698
759
|
for (const waiter of [...waiters]) finishWaiter(waiter, { threads: [], closed: true });
|
|
699
|
-
for (const client of clients) client.end();
|
|
760
|
+
for (const client of clients.keys()) client.end();
|
|
700
761
|
clients.clear();
|
|
701
762
|
}
|
|
702
|
-
|
|
763
|
+
function requestStates() {
|
|
764
|
+
const states = {};
|
|
765
|
+
for (const [id, fate] of requestFates) if (fate.state !== "over") states[id] = fate.state;
|
|
766
|
+
return states;
|
|
767
|
+
}
|
|
768
|
+
return { handle, presence, deliver, sendRequest, requestStates, close };
|
|
703
769
|
}
|
|
704
770
|
|
|
705
771
|
import fs3 from "node:fs";
|
|
772
|
+
import net from "node:net";
|
|
773
|
+
import os2 from "node:os";
|
|
706
774
|
import path2 from "node:path";
|
|
707
775
|
var STATE_FILE = ".artifact-server.json";
|
|
708
776
|
function readState(root) {
|
|
@@ -749,6 +817,78 @@ async function listen(server2, port, host) {
|
|
|
749
817
|
const address = server2.address();
|
|
750
818
|
return typeof address === "object" && address ? address.port : port;
|
|
751
819
|
}
|
|
820
|
+
var DEFAULT_PORTS = { slides: 4710, design: 4720, "design-system": 4730, dashboard: 4740, docs: 4750, motion: 4760 };
|
|
821
|
+
var PORT_TRIES = 100;
|
|
822
|
+
var PortsTaken = class extends Error {
|
|
823
|
+
code = "PORT_IN_USE";
|
|
824
|
+
from;
|
|
825
|
+
to;
|
|
826
|
+
constructor(from, to) {
|
|
827
|
+
super(`${from === to ? `Port ${from} is` : `Ports ${from} to ${to} are all`} in use; pick another --port (0 takes any free one).`);
|
|
828
|
+
this.from = from;
|
|
829
|
+
this.to = to;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
var isBenchmarkRange = (address) => /^198\.1[89]\./.test(address);
|
|
833
|
+
function lanAddresses() {
|
|
834
|
+
const out = [];
|
|
835
|
+
for (const list of Object.values(os2.networkInterfaces())) {
|
|
836
|
+
for (const entry of list ?? []) {
|
|
837
|
+
if (entry.family !== "IPv4" || entry.internal || entry.address.startsWith("169.254.") || isBenchmarkRange(entry.address)) continue;
|
|
838
|
+
out.push(entry.address);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return [...new Set(out)];
|
|
842
|
+
}
|
|
843
|
+
function answers(host, port) {
|
|
844
|
+
return new Promise((resolve) => {
|
|
845
|
+
const socket = net.connect({ host, port });
|
|
846
|
+
const done = (taken) => {
|
|
847
|
+
socket.destroy();
|
|
848
|
+
resolve(taken);
|
|
849
|
+
};
|
|
850
|
+
socket.setTimeout(300, () => done(false));
|
|
851
|
+
socket.once("connect", () => done(true));
|
|
852
|
+
socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "EAFNOSUPPORT", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
async function answersAnyPort(address) {
|
|
856
|
+
const server2 = net.createServer();
|
|
857
|
+
let port;
|
|
858
|
+
try {
|
|
859
|
+
port = await new Promise((resolve, reject) => {
|
|
860
|
+
server2.once("error", reject);
|
|
861
|
+
server2.listen(0, address, () => {
|
|
862
|
+
const bound = server2.address();
|
|
863
|
+
resolve(typeof bound === "object" && bound ? bound.port : 0);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
} finally {
|
|
869
|
+
await new Promise((resolve) => server2.close(() => resolve()));
|
|
870
|
+
}
|
|
871
|
+
return port > 0 && answers(address, port);
|
|
872
|
+
}
|
|
873
|
+
async function listenFree(server2, from, host, first) {
|
|
874
|
+
if (from === 0) return listen(server2, 0, host);
|
|
875
|
+
const to = Math.min(from + PORT_TRIES - 1, 65535);
|
|
876
|
+
const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
877
|
+
const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
|
|
878
|
+
const wildcard = host === "0.0.0.0" || host === "::";
|
|
879
|
+
const lan = lanAddresses().filter((address) => address !== host);
|
|
880
|
+
const answering = await Promise.all(lan.map(answersAnyPort));
|
|
881
|
+
const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lan.filter((_, i) => !answering[i])])];
|
|
882
|
+
for (const port of ports) {
|
|
883
|
+
if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
|
|
884
|
+
try {
|
|
885
|
+
return await listen(server2, port, host);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
throw new PortsTaken(from, to);
|
|
891
|
+
}
|
|
752
892
|
function serverOrigin(host, port) {
|
|
753
893
|
const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
|
|
754
894
|
return `http://${shown}:${port}`;
|
|
@@ -813,6 +953,1453 @@ function sendFile(request, response, file, headers) {
|
|
|
813
953
|
}
|
|
814
954
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
815
955
|
|
|
956
|
+
var MAX_EDIT = 2e3;
|
|
957
|
+
function editScript(a, b) {
|
|
958
|
+
const n = a.length;
|
|
959
|
+
const m = b.length;
|
|
960
|
+
const limit = Math.min(n + m, MAX_EDIT);
|
|
961
|
+
const offset = limit + 1;
|
|
962
|
+
const v = new Int32Array(2 * limit + 3);
|
|
963
|
+
const trace = [];
|
|
964
|
+
let found = -1;
|
|
965
|
+
for (let d = 0; d <= limit; d++) {
|
|
966
|
+
trace.push(v.slice(offset - d - 1, offset + d + 2));
|
|
967
|
+
for (let k = -d; k <= d; k += 2) {
|
|
968
|
+
let x2 = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
|
|
969
|
+
let y2 = x2 - k;
|
|
970
|
+
while (x2 < n && y2 < m && a[x2] === b[y2]) {
|
|
971
|
+
x2++;
|
|
972
|
+
y2++;
|
|
973
|
+
}
|
|
974
|
+
v[offset + k] = x2;
|
|
975
|
+
if (x2 >= n && y2 >= m) {
|
|
976
|
+
found = d;
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (found >= 0) break;
|
|
981
|
+
}
|
|
982
|
+
if (found < 0) return null;
|
|
983
|
+
const ops = [];
|
|
984
|
+
let x = n;
|
|
985
|
+
let y = m;
|
|
986
|
+
for (let d = found; d > 0; d--) {
|
|
987
|
+
const prev = trace[d];
|
|
988
|
+
const at = (k2) => prev[k2 + d + 1];
|
|
989
|
+
const k = x - y;
|
|
990
|
+
const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
|
|
991
|
+
const prevX = at(prevK);
|
|
992
|
+
const prevY = prevX - prevK;
|
|
993
|
+
while (x > prevX && y > prevY) {
|
|
994
|
+
ops.push("=");
|
|
995
|
+
x--;
|
|
996
|
+
y--;
|
|
997
|
+
}
|
|
998
|
+
ops.push(x === prevX ? "+" : "-");
|
|
999
|
+
x = prevX;
|
|
1000
|
+
y = prevY;
|
|
1001
|
+
}
|
|
1002
|
+
while (x > 0 && y > 0) {
|
|
1003
|
+
ops.push("=");
|
|
1004
|
+
x--;
|
|
1005
|
+
y--;
|
|
1006
|
+
}
|
|
1007
|
+
return ops.reverse();
|
|
1008
|
+
}
|
|
1009
|
+
function hunksOf(base, side, mine) {
|
|
1010
|
+
const ops = editScript(base, side);
|
|
1011
|
+
if (!ops) return null;
|
|
1012
|
+
const out = [];
|
|
1013
|
+
let open = null;
|
|
1014
|
+
let i = 0;
|
|
1015
|
+
let j = 0;
|
|
1016
|
+
for (const op of ops) {
|
|
1017
|
+
if (op === "=") {
|
|
1018
|
+
open = null;
|
|
1019
|
+
i++;
|
|
1020
|
+
j++;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (!open) out.push(open = { start: i, end: i, lines: [], mine });
|
|
1024
|
+
if (op === "-") open.end = ++i;
|
|
1025
|
+
else open.lines.push(side[j++]);
|
|
1026
|
+
}
|
|
1027
|
+
return out.flatMap(
|
|
1028
|
+
(h) => h.end - h.start > 1 && h.lines.length === h.end - h.start ? h.lines.flatMap((line, k) => {
|
|
1029
|
+
const at = h.start + k;
|
|
1030
|
+
return line === base[at] ? [] : [{ start: at, end: at + 1, lines: [line], mine }];
|
|
1031
|
+
}) : [h]
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
function applyHunks(base, start, end, hunks) {
|
|
1035
|
+
const out = [];
|
|
1036
|
+
let at = start;
|
|
1037
|
+
for (const h of hunks) {
|
|
1038
|
+
out.push(...base.slice(at, h.start), ...h.lines);
|
|
1039
|
+
at = h.end;
|
|
1040
|
+
}
|
|
1041
|
+
out.push(...base.slice(at, end));
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
1044
|
+
function mergeText(base, mine, current, at, conflicts) {
|
|
1045
|
+
const lines = base.split("\n");
|
|
1046
|
+
const ours = hunksOf(lines, mine.split("\n"), true);
|
|
1047
|
+
const theirs = ours && hunksOf(lines, current.split("\n"), false);
|
|
1048
|
+
if (!ours || !theirs) return null;
|
|
1049
|
+
const all = [...ours, ...theirs].sort((x, y) => x.start - y.start || x.end - y.end);
|
|
1050
|
+
const out = [];
|
|
1051
|
+
const lost = [];
|
|
1052
|
+
let done = 0;
|
|
1053
|
+
for (let i = 0; i < all.length; ) {
|
|
1054
|
+
const region = [all[i++]];
|
|
1055
|
+
const start = region[0].start;
|
|
1056
|
+
let end = region[0].end;
|
|
1057
|
+
for (; i < all.length; i++) {
|
|
1058
|
+
const h = all[i];
|
|
1059
|
+
if (!(h.start < end || h.start === h.end && start === end && h.start === start)) break;
|
|
1060
|
+
region.push(h);
|
|
1061
|
+
end = Math.max(end, h.end);
|
|
1062
|
+
}
|
|
1063
|
+
out.push(...lines.slice(done, start));
|
|
1064
|
+
const mineLines = applyHunks(lines, start, end, region.filter((h) => h.mine));
|
|
1065
|
+
if (region.every((h) => h.mine === region[0].mine)) out.push(...applyHunks(lines, start, end, region));
|
|
1066
|
+
else {
|
|
1067
|
+
const theirLines = applyHunks(lines, start, end, region.filter((h) => !h.mine));
|
|
1068
|
+
if (mineLines.join("\n") !== theirLines.join("\n")) lost.push(`${at}@${start + 1}`);
|
|
1069
|
+
out.push(...mineLines);
|
|
1070
|
+
}
|
|
1071
|
+
done = end;
|
|
1072
|
+
}
|
|
1073
|
+
out.push(...lines.slice(done));
|
|
1074
|
+
conflicts.push(...lost);
|
|
1075
|
+
return out.join("\n");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
var utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
1079
|
+
function mergeTextBytes(base, mine, current) {
|
|
1080
|
+
const text = (bytes) => bytes.includes(0) ? null : utf8.decode(bytes);
|
|
1081
|
+
try {
|
|
1082
|
+
const [b, m, c] = [text(base), text(mine), text(current)];
|
|
1083
|
+
if (b === null || m === null || c === null) return null;
|
|
1084
|
+
const merged = mergeText(b, m, c, ".", []);
|
|
1085
|
+
return merged === null ? null : Buffer.from(merged, "utf8");
|
|
1086
|
+
} catch {
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
1092
|
+
import fs4 from "node:fs";
|
|
1093
|
+
import path3 from "node:path";
|
|
1094
|
+
var HISTORY_DIR = "history";
|
|
1095
|
+
var INDEX = "index.json";
|
|
1096
|
+
var OBJECTS = "objects";
|
|
1097
|
+
var IDLE_MS = 3e4;
|
|
1098
|
+
var SPAN_MS = 5 * 6e4;
|
|
1099
|
+
var MAX_VERSIONS = 500;
|
|
1100
|
+
var MAX_NOTICES = 50;
|
|
1101
|
+
var NOTICE_DAYS = 7;
|
|
1102
|
+
var AGENT = { by: "agent", user: { id: "agent", name: "Agent" } };
|
|
1103
|
+
var SYSTEM = { by: "system", user: { id: "system", name: "" } };
|
|
1104
|
+
var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
1105
|
+
var SHA = /^[0-9a-f]{64}$/;
|
|
1106
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1107
|
+
function writeAtomic2(file, data) {
|
|
1108
|
+
fs4.mkdirSync(path3.dirname(file), { recursive: true });
|
|
1109
|
+
const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
1110
|
+
try {
|
|
1111
|
+
fs4.writeFileSync(tmp, data);
|
|
1112
|
+
fs4.renameSync(tmp, file);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
fs4.rmSync(tmp, { force: true });
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
var emptyIndex = () => ({ format: 1, next: 1, base: {}, versions: [], notices: [] });
|
|
1119
|
+
function readUser(value) {
|
|
1120
|
+
return isRecord2(value) && typeof value.id === "string" && typeof value.name === "string" ? { id: value.id, name: value.name } : null;
|
|
1121
|
+
}
|
|
1122
|
+
var isAuthor = (value) => value === "human" || value === "agent" || value === "system";
|
|
1123
|
+
function parseIndex(raw) {
|
|
1124
|
+
const index = emptyIndex();
|
|
1125
|
+
if (!isRecord2(raw)) return index;
|
|
1126
|
+
if (isRecord2(raw.base)) {
|
|
1127
|
+
for (const [p, s] of Object.entries(raw.base)) if (typeof s === "string" && SHA.test(s)) index.base[p] = s;
|
|
1128
|
+
}
|
|
1129
|
+
if (Array.isArray(raw.versions)) {
|
|
1130
|
+
for (const v of raw.versions) {
|
|
1131
|
+
if (!isRecord2(v) || typeof v.n !== "number" || !isAuthor(v.by) || typeof v.at !== "string" || !isRecord2(v.changes)) continue;
|
|
1132
|
+
const user = readUser(v.user);
|
|
1133
|
+
if (!user) continue;
|
|
1134
|
+
const changes = {};
|
|
1135
|
+
for (const [p, s] of Object.entries(v.changes)) if (s === null || typeof s === "string" && SHA.test(s)) changes[p] = s;
|
|
1136
|
+
const version = { n: v.n, first: typeof v.first === "string" ? v.first : v.at, at: v.at, by: v.by, user, changes };
|
|
1137
|
+
if (typeof v.role === "string") version.role = v.role;
|
|
1138
|
+
if (typeof v.restoredFrom === "number") version.restoredFrom = v.restoredFrom;
|
|
1139
|
+
if (version.restoredFrom !== void 0 && v.restoredPart === true) version.restoredPart = true;
|
|
1140
|
+
if (Array.isArray(v.overwrote)) {
|
|
1141
|
+
const list = [];
|
|
1142
|
+
for (const o of v.overwrote) {
|
|
1143
|
+
const who = isRecord2(o) ? readUser(o.user) : null;
|
|
1144
|
+
if (!isRecord2(o) || !who || typeof o.path !== "string" || o.lost !== null && typeof o.lost !== "string" || typeof o.n !== "number" || !isAuthor(o.by)) continue;
|
|
1145
|
+
list.push({
|
|
1146
|
+
path: o.path,
|
|
1147
|
+
lost: o.lost,
|
|
1148
|
+
n: o.n,
|
|
1149
|
+
by: o.by,
|
|
1150
|
+
user: who,
|
|
1151
|
+
...o.soft === true ? { soft: true } : {},
|
|
1152
|
+
...o.chosen === true ? { chosen: true } : {},
|
|
1153
|
+
...typeof o.base === "string" && SHA.test(o.base) ? { base: o.base } : {}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
if (list.length) version.overwrote = list;
|
|
1157
|
+
}
|
|
1158
|
+
index.versions.push(version);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
index.versions.sort((a, b) => a.n - b.n);
|
|
1162
|
+
const last = index.versions.at(-1);
|
|
1163
|
+
index.next = Math.max(typeof raw.next === "number" ? raw.next : 1, (last?.n ?? 0) + 1);
|
|
1164
|
+
if (Array.isArray(raw.notices)) {
|
|
1165
|
+
for (const n of raw.notices) {
|
|
1166
|
+
if (!isRecord2(n) || typeof n.id !== "string" || typeof n.to !== "string" || typeof n.path !== "string" || typeof n.n !== "number") continue;
|
|
1167
|
+
if (n.kind !== "overwritten" && n.kind !== "overwrote") continue;
|
|
1168
|
+
const by = readUser(n.by);
|
|
1169
|
+
const lost = isRecord2(n.lost) ? n.lost : null;
|
|
1170
|
+
const lostUser = lost ? readUser(lost.user) : null;
|
|
1171
|
+
if (!by || !lost || !lostUser || lost.sha !== null && typeof lost.sha !== "string" || typeof lost.n !== "number" || !isAuthor(lost.by)) continue;
|
|
1172
|
+
index.notices.push({
|
|
1173
|
+
id: n.id,
|
|
1174
|
+
to: n.to,
|
|
1175
|
+
at: typeof n.at === "string" ? n.at : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1176
|
+
kind: n.kind,
|
|
1177
|
+
path: n.path,
|
|
1178
|
+
n: n.n,
|
|
1179
|
+
by,
|
|
1180
|
+
byAuthor: isAuthor(n.byAuthor) ? n.byAuthor : "human",
|
|
1181
|
+
lost: { sha: lost.sha, n: lost.n, by: lost.by, user: lostUser },
|
|
1182
|
+
...n.soft === true ? { soft: true } : {},
|
|
1183
|
+
...typeof n.base === "string" && SHA.test(n.base) ? { base: n.base } : {}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return index;
|
|
1188
|
+
}
|
|
1189
|
+
var History = class {
|
|
1190
|
+
dir;
|
|
1191
|
+
index;
|
|
1192
|
+
/** path → sha: what the files hold after the newest version. */
|
|
1193
|
+
headState;
|
|
1194
|
+
constructor(root) {
|
|
1195
|
+
this.dir = path3.join(root, HISTORY_DIR);
|
|
1196
|
+
let raw = null;
|
|
1197
|
+
try {
|
|
1198
|
+
raw = JSON.parse(fs4.readFileSync(path3.join(this.dir, INDEX), "utf8"));
|
|
1199
|
+
} catch {
|
|
1200
|
+
}
|
|
1201
|
+
this.index = parseIndex(raw);
|
|
1202
|
+
this.headState = this.fold(Infinity);
|
|
1203
|
+
this.index.notices = this.index.notices.filter((n) => (this.headState.get(n.path) ?? null) !== n.lost.sha);
|
|
1204
|
+
}
|
|
1205
|
+
get empty() {
|
|
1206
|
+
return this.index.versions.length === 0 && Object.keys(this.index.base).length === 0;
|
|
1207
|
+
}
|
|
1208
|
+
/** What the files hold now, as far as the history knows (path → sha). */
|
|
1209
|
+
head() {
|
|
1210
|
+
return this.headState;
|
|
1211
|
+
}
|
|
1212
|
+
versions() {
|
|
1213
|
+
return this.index.versions;
|
|
1214
|
+
}
|
|
1215
|
+
version(n) {
|
|
1216
|
+
return this.index.versions.find((v) => v.n === n);
|
|
1217
|
+
}
|
|
1218
|
+
/** The files after the versions numbered up to `upto` (the base and every version at or below it). */
|
|
1219
|
+
fold(upto) {
|
|
1220
|
+
const state = new Map(Object.entries(this.index.base));
|
|
1221
|
+
for (const version of this.index.versions) {
|
|
1222
|
+
if (version.n > upto) break;
|
|
1223
|
+
for (const [p, s] of Object.entries(version.changes)) {
|
|
1224
|
+
if (s === null) state.delete(p);
|
|
1225
|
+
else state.set(p, s);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return state;
|
|
1229
|
+
}
|
|
1230
|
+
/** The files right after version `n`; null when it is not kept. */
|
|
1231
|
+
stateAt(n) {
|
|
1232
|
+
return this.version(n) ? this.fold(n) : null;
|
|
1233
|
+
}
|
|
1234
|
+
/** The files right before version `n`. */
|
|
1235
|
+
stateBefore(n) {
|
|
1236
|
+
return this.fold(n - 1);
|
|
1237
|
+
}
|
|
1238
|
+
/** The content with this sha, from the objects. */
|
|
1239
|
+
read(sha) {
|
|
1240
|
+
if (!SHA.test(sha)) return null;
|
|
1241
|
+
try {
|
|
1242
|
+
return fs4.readFileSync(path3.join(this.dir, OBJECTS, sha));
|
|
1243
|
+
} catch {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/** Keeps a content that no version holds (an overwrite's `base`); its sha. */
|
|
1248
|
+
put(bytes) {
|
|
1249
|
+
const sha = sha256(bytes);
|
|
1250
|
+
this.putObject(sha, bytes);
|
|
1251
|
+
return sha;
|
|
1252
|
+
}
|
|
1253
|
+
size(sha) {
|
|
1254
|
+
if (!SHA.test(sha)) return null;
|
|
1255
|
+
try {
|
|
1256
|
+
return fs4.statSync(path3.join(this.dir, OBJECTS, sha)).size;
|
|
1257
|
+
} catch {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/** The newest version that changed `path`, with what it left there. */
|
|
1262
|
+
lastChange(p) {
|
|
1263
|
+
for (let i = this.index.versions.length - 1; i >= 0; i--) {
|
|
1264
|
+
const version = this.index.versions[i];
|
|
1265
|
+
if (p in version.changes) return { version, sha: version.changes[p] };
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Records changes: path → its content now (null: deleted). Those equal to the head are left out; nothing left, no
|
|
1271
|
+
* version (null). The rest join the open version of the same person, or start a new one.
|
|
1272
|
+
*/
|
|
1273
|
+
record(changes, who, extra = {}, now = /* @__PURE__ */ new Date()) {
|
|
1274
|
+
const changed = {};
|
|
1275
|
+
for (const [p, bytes] of changes) {
|
|
1276
|
+
const sha = bytes === null ? null : sha256(bytes);
|
|
1277
|
+
if ((this.headState.get(p) ?? null) === sha) continue;
|
|
1278
|
+
if (bytes !== null && sha !== null) this.putObject(sha, bytes);
|
|
1279
|
+
changed[p] = sha;
|
|
1280
|
+
}
|
|
1281
|
+
if (Object.keys(changed).length === 0) return null;
|
|
1282
|
+
const iso = now.toISOString();
|
|
1283
|
+
const last = this.index.versions.at(-1);
|
|
1284
|
+
const joins = !!last && who.by !== "system" && last.by === who.by && last.user.id === who.user.id && last.restoredFrom === void 0 && extra.restoredFrom === void 0 && now.getTime() - Date.parse(last.at) < IDLE_MS && now.getTime() - Date.parse(last.first) < SPAN_MS;
|
|
1285
|
+
let version;
|
|
1286
|
+
if (joins) {
|
|
1287
|
+
version = last;
|
|
1288
|
+
version.at = iso;
|
|
1289
|
+
Object.assign(version.changes, changed);
|
|
1290
|
+
if (who.user.name) version.user = { ...who.user };
|
|
1291
|
+
} else {
|
|
1292
|
+
version = { n: this.index.next++, first: iso, at: iso, by: who.by, user: { ...who.user }, changes: changed };
|
|
1293
|
+
if (who.role) version.role = who.role;
|
|
1294
|
+
if (extra.restoredFrom !== void 0) version.restoredFrom = extra.restoredFrom;
|
|
1295
|
+
if (extra.restoredFrom !== void 0 && extra.restoredPart) version.restoredPart = true;
|
|
1296
|
+
this.index.versions.push(version);
|
|
1297
|
+
}
|
|
1298
|
+
for (const [p, s] of Object.entries(changed)) {
|
|
1299
|
+
if (s === null) this.headState.delete(p);
|
|
1300
|
+
else this.headState.set(p, s);
|
|
1301
|
+
}
|
|
1302
|
+
this.index.notices = this.index.notices.filter(
|
|
1303
|
+
(n) => !(n.path in changed && (changed[n.path] === n.lost.sha || extra.restoredFrom !== void 0 && n.to === who.user.id))
|
|
1304
|
+
);
|
|
1305
|
+
if (extra.overwrote?.length) {
|
|
1306
|
+
version.overwrote = [...(version.overwrote ?? []).filter((o) => !extra.overwrote.some((x) => x.path === o.path)), ...extra.overwrote];
|
|
1307
|
+
this.notify(version, who, extra.overwrote, now);
|
|
1308
|
+
}
|
|
1309
|
+
this.prune(now);
|
|
1310
|
+
this.save();
|
|
1311
|
+
return version;
|
|
1312
|
+
}
|
|
1313
|
+
/** Notices for an overwrite: to whoever lost their change (the agent's go to the owner), and a hard one's saver unless they chose it. */
|
|
1314
|
+
notify(version, who, overwrote, now) {
|
|
1315
|
+
const at = now.toISOString();
|
|
1316
|
+
for (const o of overwrote) {
|
|
1317
|
+
const lost = { sha: o.lost, n: o.n, by: o.by, user: o.user };
|
|
1318
|
+
const victim = o.by === "agent" ? "local" : o.user.id;
|
|
1319
|
+
const fields = {
|
|
1320
|
+
at,
|
|
1321
|
+
path: o.path,
|
|
1322
|
+
n: version.n,
|
|
1323
|
+
by: { ...who.user },
|
|
1324
|
+
byAuthor: who.by,
|
|
1325
|
+
lost,
|
|
1326
|
+
...o.soft ? { soft: true } : {},
|
|
1327
|
+
...o.base ? { base: o.base } : {}
|
|
1328
|
+
};
|
|
1329
|
+
const same = (n) => n.path === o.path && n.n === version.n;
|
|
1330
|
+
this.index.notices = this.index.notices.filter((n) => !same(n));
|
|
1331
|
+
if (victim !== who.user.id) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: victim, kind: "overwritten", ...fields });
|
|
1332
|
+
if (!o.soft && !o.chosen) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: who.user.id, kind: "overwrote", ...fields });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
/** The notices for this person, newest first. */
|
|
1336
|
+
noticesFor(userId) {
|
|
1337
|
+
return this.index.notices.filter((n) => n.to === userId).reverse();
|
|
1338
|
+
}
|
|
1339
|
+
notice(id) {
|
|
1340
|
+
return this.index.notices.find((n) => n.id === id);
|
|
1341
|
+
}
|
|
1342
|
+
/** Dismisses one of this person's notices; false when they have none by that id. */
|
|
1343
|
+
dismiss(userId, id) {
|
|
1344
|
+
const before = this.index.notices.length;
|
|
1345
|
+
this.index.notices = this.index.notices.filter((n) => !(n.id === id && n.to === userId));
|
|
1346
|
+
if (this.index.notices.length === before) return false;
|
|
1347
|
+
this.save();
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* What to write to bring version `n` back: path → content (null: delete), for `paths` (default: every file it or the
|
|
1352
|
+
* head holds), only where the head differs. null when the version is not kept or a content is missing.
|
|
1353
|
+
*/
|
|
1354
|
+
restoreTo(n, paths, tracked = () => true) {
|
|
1355
|
+
const target = this.stateAt(n);
|
|
1356
|
+
if (!target) return null;
|
|
1357
|
+
const out = /* @__PURE__ */ new Map();
|
|
1358
|
+
const all = paths ?? [.../* @__PURE__ */ new Set([...target.keys(), ...this.headState.keys()])];
|
|
1359
|
+
for (const p of all) {
|
|
1360
|
+
if (!tracked(p)) continue;
|
|
1361
|
+
const want = target.get(p) ?? null;
|
|
1362
|
+
if ((this.headState.get(p) ?? null) === want) continue;
|
|
1363
|
+
if (want === null) {
|
|
1364
|
+
out.set(p, null);
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const bytes = this.read(want);
|
|
1368
|
+
if (!bytes) return null;
|
|
1369
|
+
out.set(p, bytes);
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
/** Each version's changes as added, edited or deleted (what the file held before it decides). */
|
|
1374
|
+
fileChanges() {
|
|
1375
|
+
const out = /* @__PURE__ */ new Map();
|
|
1376
|
+
const state = new Map(Object.entries(this.index.base));
|
|
1377
|
+
for (const version of this.index.versions) {
|
|
1378
|
+
const list = [];
|
|
1379
|
+
for (const [p, s] of Object.entries(version.changes)) {
|
|
1380
|
+
const had = state.has(p);
|
|
1381
|
+
list.push({ path: p, op: s === null ? "deleted" : had ? "edited" : "added" });
|
|
1382
|
+
if (s === null) state.delete(p);
|
|
1383
|
+
else state.set(p, s);
|
|
1384
|
+
}
|
|
1385
|
+
out.set(version.n, list);
|
|
1386
|
+
}
|
|
1387
|
+
return out;
|
|
1388
|
+
}
|
|
1389
|
+
putObject(sha, bytes) {
|
|
1390
|
+
const file = path3.join(this.dir, OBJECTS, sha);
|
|
1391
|
+
if (fs4.existsSync(file)) return;
|
|
1392
|
+
writeAtomic2(file, bytes);
|
|
1393
|
+
}
|
|
1394
|
+
/** Past MAX_VERSIONS the oldest fold into the base; old notices go; contents no one holds are removed. */
|
|
1395
|
+
prune(now) {
|
|
1396
|
+
let folded = false;
|
|
1397
|
+
while (this.index.versions.length > MAX_VERSIONS) {
|
|
1398
|
+
const old = this.index.versions.shift();
|
|
1399
|
+
for (const [p, s] of Object.entries(old.changes)) {
|
|
1400
|
+
if (s === null) delete this.index.base[p];
|
|
1401
|
+
else this.index.base[p] = s;
|
|
1402
|
+
}
|
|
1403
|
+
folded = true;
|
|
1404
|
+
}
|
|
1405
|
+
const oldest = now.getTime() - NOTICE_DAYS * 864e5;
|
|
1406
|
+
const kept = this.index.versions[0]?.n ?? Infinity;
|
|
1407
|
+
const perPerson = /* @__PURE__ */ new Map();
|
|
1408
|
+
this.index.notices = this.index.notices.filter((n) => Date.parse(n.at) >= oldest && n.lost.n >= kept).reverse().filter((n) => {
|
|
1409
|
+
const count = (perPerson.get(n.to) ?? 0) + 1;
|
|
1410
|
+
perPerson.set(n.to, count);
|
|
1411
|
+
return count <= MAX_NOTICES;
|
|
1412
|
+
}).reverse();
|
|
1413
|
+
if (folded) this.collect();
|
|
1414
|
+
}
|
|
1415
|
+
/** Removes the objects that neither the base, a version nor a notice holds (with the base an overwrite was made on). */
|
|
1416
|
+
collect() {
|
|
1417
|
+
const keep = new Set(Object.values(this.index.base));
|
|
1418
|
+
for (const version of this.index.versions) {
|
|
1419
|
+
for (const s of Object.values(version.changes)) if (s) keep.add(s);
|
|
1420
|
+
for (const o of version.overwrote ?? []) if (o.base) keep.add(o.base);
|
|
1421
|
+
}
|
|
1422
|
+
for (const notice of this.index.notices) {
|
|
1423
|
+
if (notice.lost.sha) keep.add(notice.lost.sha);
|
|
1424
|
+
if (notice.base) keep.add(notice.base);
|
|
1425
|
+
}
|
|
1426
|
+
let names;
|
|
1427
|
+
try {
|
|
1428
|
+
names = fs4.readdirSync(path3.join(this.dir, OBJECTS));
|
|
1429
|
+
} catch {
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
for (const name of names) if (SHA.test(name) && !keep.has(name)) fs4.rmSync(path3.join(this.dir, OBJECTS, name), { force: true });
|
|
1433
|
+
}
|
|
1434
|
+
save() {
|
|
1435
|
+
writeAtomic2(path3.join(this.dir, INDEX), `${JSON.stringify(this.index)}
|
|
1436
|
+
`);
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
var MAX_DIFF_BYTES = 1024 * 1024;
|
|
1440
|
+
var CONTEXT = 3;
|
|
1441
|
+
var MAX_DIFF_LINES = 3e3;
|
|
1442
|
+
var MAX_LINE = 2e3;
|
|
1443
|
+
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1444
|
+
function textOf(bytes) {
|
|
1445
|
+
if (bytes === null) return null;
|
|
1446
|
+
if (bytes.includes(0)) return void 0;
|
|
1447
|
+
try {
|
|
1448
|
+
return decoder.decode(bytes);
|
|
1449
|
+
} catch {
|
|
1450
|
+
return void 0;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
var splitLines = (text) => text === null || text === "" ? [] : text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
|
|
1454
|
+
function diffContents(before, after) {
|
|
1455
|
+
if ((before?.length ?? 0) > MAX_DIFF_BYTES || (after?.length ?? 0) > MAX_DIFF_BYTES) return { tooLarge: true, added: 0, removed: 0, lines: [] };
|
|
1456
|
+
const a = textOf(before);
|
|
1457
|
+
const b = textOf(after);
|
|
1458
|
+
if (a === void 0 || b === void 0) return { binary: true, added: 0, removed: 0, lines: [] };
|
|
1459
|
+
const left = splitLines(a);
|
|
1460
|
+
const right = splitLines(b);
|
|
1461
|
+
let head = 0;
|
|
1462
|
+
while (head < left.length && head < right.length && left[head] === right[head]) head++;
|
|
1463
|
+
let tail = 0;
|
|
1464
|
+
while (tail < left.length - head && tail < right.length - head && left[left.length - 1 - tail] === right[right.length - 1 - tail]) tail++;
|
|
1465
|
+
const middle = editScript(left.slice(head, left.length - tail), right.slice(head, right.length - tail));
|
|
1466
|
+
if (!middle) return { tooLarge: true, added: 0, removed: 0, lines: [] };
|
|
1467
|
+
const ops = [...Array(head).fill("="), ...middle, ...Array(tail).fill("=")];
|
|
1468
|
+
const all = [];
|
|
1469
|
+
let i = 0;
|
|
1470
|
+
let j = 0;
|
|
1471
|
+
let added = 0;
|
|
1472
|
+
let removed = 0;
|
|
1473
|
+
const cut = (s) => s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}\u2026` : s;
|
|
1474
|
+
for (const op of ops) {
|
|
1475
|
+
if (op === "=") all.push([" ", cut(right[j++])]), i++;
|
|
1476
|
+
else if (op === "-") all.push(["-", cut(left[i++])]), removed++;
|
|
1477
|
+
else all.push(["+", cut(right[j++])]), added++;
|
|
1478
|
+
}
|
|
1479
|
+
const keep = new Uint8Array(all.length);
|
|
1480
|
+
all.forEach(([kind], at) => {
|
|
1481
|
+
if (kind === " ") return;
|
|
1482
|
+
for (let x = Math.max(0, at - CONTEXT); x <= Math.min(all.length - 1, at + CONTEXT); x++) keep[x] = 1;
|
|
1483
|
+
});
|
|
1484
|
+
const lines = [];
|
|
1485
|
+
let skipped = 0;
|
|
1486
|
+
let truncated = false;
|
|
1487
|
+
for (let at = 0; at < all.length; at++) {
|
|
1488
|
+
if (!keep[at]) {
|
|
1489
|
+
skipped++;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (skipped) lines.push(["~", String(skipped)]);
|
|
1493
|
+
skipped = 0;
|
|
1494
|
+
if (lines.length >= MAX_DIFF_LINES) {
|
|
1495
|
+
truncated = true;
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
lines.push(all[at]);
|
|
1499
|
+
}
|
|
1500
|
+
if (skipped && !truncated) lines.push(["~", String(skipped)]);
|
|
1501
|
+
return { added, removed, lines, ...truncated ? { truncated: true } : {} };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1505
|
+
import fs6 from "node:fs";
|
|
1506
|
+
import os3 from "node:os";
|
|
1507
|
+
import path5 from "node:path";
|
|
1508
|
+
|
|
1509
|
+
import crypto from "node:crypto";
|
|
1510
|
+
import fs5 from "node:fs";
|
|
1511
|
+
import path4 from "node:path";
|
|
1512
|
+
var GUEST_ROLES = ["editor", "commenter", "viewer"];
|
|
1513
|
+
var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
|
|
1514
|
+
var SHARING_FILE = ".sharing.json";
|
|
1515
|
+
var MAX_GUESTS = 200;
|
|
1516
|
+
var MAX_NAME = 40;
|
|
1517
|
+
var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
|
|
1518
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1519
|
+
function fresh() {
|
|
1520
|
+
return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
|
|
1521
|
+
}
|
|
1522
|
+
function cleanName(raw) {
|
|
1523
|
+
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
|
|
1524
|
+
return [...name].slice(0, MAX_NAME).join("");
|
|
1525
|
+
}
|
|
1526
|
+
function guestUser(guest) {
|
|
1527
|
+
return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
|
|
1528
|
+
}
|
|
1529
|
+
function isLoopback(address) {
|
|
1530
|
+
if (!address) return false;
|
|
1531
|
+
const a = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
1532
|
+
return a === "::1" || a.startsWith("127.");
|
|
1533
|
+
}
|
|
1534
|
+
var Sharing = class _Sharing {
|
|
1535
|
+
file;
|
|
1536
|
+
state;
|
|
1537
|
+
constructor(root) {
|
|
1538
|
+
this.file = path4.join(root, SHARING_FILE);
|
|
1539
|
+
this.state = _Sharing.read(this.file);
|
|
1540
|
+
}
|
|
1541
|
+
static read(file) {
|
|
1542
|
+
let raw;
|
|
1543
|
+
try {
|
|
1544
|
+
raw = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
1545
|
+
} catch {
|
|
1546
|
+
return fresh();
|
|
1547
|
+
}
|
|
1548
|
+
const state = fresh();
|
|
1549
|
+
if (!isRecord3(raw)) return state;
|
|
1550
|
+
state.lan = raw.lan === true;
|
|
1551
|
+
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
|
|
1552
|
+
if (isRecord3(raw.links)) {
|
|
1553
|
+
for (const role of GUEST_ROLES) {
|
|
1554
|
+
const link = raw.links[role];
|
|
1555
|
+
if (isRecord3(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (Array.isArray(raw.guests)) {
|
|
1559
|
+
for (const g of raw.guests) {
|
|
1560
|
+
if (!isRecord3(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
1561
|
+
state.guests.push({
|
|
1562
|
+
id: g.id,
|
|
1563
|
+
secret: g.secret,
|
|
1564
|
+
name: cleanName(g.name) || "Guest",
|
|
1565
|
+
role: g.role,
|
|
1566
|
+
link: g.link,
|
|
1567
|
+
joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1568
|
+
...typeof g.seen === "string" ? { seen: g.seen } : {}
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return state;
|
|
1573
|
+
}
|
|
1574
|
+
save() {
|
|
1575
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
1576
|
+
fs5.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
|
|
1577
|
+
`, { mode: 384 });
|
|
1578
|
+
fs5.renameSync(tmp, this.file);
|
|
1579
|
+
}
|
|
1580
|
+
/** The role whose link has this token, while that link is on. */
|
|
1581
|
+
linkRole(tokenGiven) {
|
|
1582
|
+
for (const role of GUEST_ROLES) {
|
|
1583
|
+
const link = this.state.links[role];
|
|
1584
|
+
if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
|
|
1585
|
+
}
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
1589
|
+
guest(secret) {
|
|
1590
|
+
if (!secret) return null;
|
|
1591
|
+
const guest = this.state.guests.find((g) => g.secret === secret);
|
|
1592
|
+
return guest && this.valid(guest) ? guest : null;
|
|
1593
|
+
}
|
|
1594
|
+
valid(guest) {
|
|
1595
|
+
const link = this.state.links[guest.role];
|
|
1596
|
+
return link.on && link.token === guest.link && this.state.guests.includes(guest);
|
|
1597
|
+
}
|
|
1598
|
+
join(role, name) {
|
|
1599
|
+
const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1600
|
+
this.state.guests.push(guest);
|
|
1601
|
+
if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
|
|
1602
|
+
this.save();
|
|
1603
|
+
return guest;
|
|
1604
|
+
}
|
|
1605
|
+
/** Marks a guest seen now (saved at most once a minute per guest). */
|
|
1606
|
+
seen(guest) {
|
|
1607
|
+
const now = /* @__PURE__ */ new Date();
|
|
1608
|
+
if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
|
|
1609
|
+
guest.seen = now.toISOString();
|
|
1610
|
+
this.save();
|
|
1611
|
+
}
|
|
1612
|
+
setLan(on, port) {
|
|
1613
|
+
this.state.lan = on;
|
|
1614
|
+
if (on && port) this.state.port = port;
|
|
1615
|
+
this.save();
|
|
1616
|
+
}
|
|
1617
|
+
setLink(role, on) {
|
|
1618
|
+
this.state.links[role].on = on;
|
|
1619
|
+
this.save();
|
|
1620
|
+
}
|
|
1621
|
+
/** A new token for the link: the old link stops working and the guests who came through it are dropped. */
|
|
1622
|
+
resetLink(role) {
|
|
1623
|
+
const old = this.state.links[role].token;
|
|
1624
|
+
this.state.links[role] = { token: token(), on: this.state.links[role].on };
|
|
1625
|
+
this.state.guests = this.state.guests.filter((g) => g.link !== old);
|
|
1626
|
+
this.save();
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
|
|
1630
|
+
* whose link is off would shut them out: false then, as for a guest who isn't there.
|
|
1631
|
+
*/
|
|
1632
|
+
setRole(id, role) {
|
|
1633
|
+
const guest = this.state.guests.find((g) => g.id === id);
|
|
1634
|
+
if (!guest || !this.state.links[role].on) return false;
|
|
1635
|
+
guest.role = role;
|
|
1636
|
+
guest.link = this.state.links[role].token;
|
|
1637
|
+
this.save();
|
|
1638
|
+
return true;
|
|
1639
|
+
}
|
|
1640
|
+
removeGuest(id) {
|
|
1641
|
+
const before = this.state.guests.length;
|
|
1642
|
+
this.state.guests = this.state.guests.filter((g) => g.id !== id);
|
|
1643
|
+
if (this.state.guests.length === before) return false;
|
|
1644
|
+
this.save();
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
var NOUNS = {
|
|
1650
|
+
docs: { en: "doc", zh: "\u6587\u6863", zhThis: "\u8FD9\u7BC7\u6587\u6863" },
|
|
1651
|
+
slides: { en: "deck", zh: "\u6F14\u793A\u6587\u7A3F", zhThis: "\u8FD9\u4EFD\u6F14\u793A\u6587\u7A3F" },
|
|
1652
|
+
design: { en: "design", zh: "\u8BBE\u8BA1\u7A3F", zhThis: "\u8FD9\u4EFD\u8BBE\u8BA1\u7A3F" },
|
|
1653
|
+
"design-system": { en: "design system", zh: "\u8BBE\u8BA1\u7CFB\u7EDF", zhThis: "\u8FD9\u4E2A\u8BBE\u8BA1\u7CFB\u7EDF" },
|
|
1654
|
+
dashboard: { en: "dashboard", zh: "\u4EEA\u8868\u76D8", zhThis: "\u8FD9\u4E2A\u4EEA\u8868\u76D8" },
|
|
1655
|
+
motion: { en: "animation", zh: "\u52A8\u753B", zhThis: "\u8FD9\u4E2A\u52A8\u753B" }
|
|
1656
|
+
};
|
|
1657
|
+
var ShareError = class extends Error {
|
|
1658
|
+
};
|
|
1659
|
+
var canEditAs = (role) => role === "owner" || role === "editor";
|
|
1660
|
+
function commentsViewer(viewer) {
|
|
1661
|
+
return {
|
|
1662
|
+
author: viewer.guest ? { kind: "user", name: viewer.user.name, principal: viewer.user.email } : { kind: "user", name: viewer.user.name },
|
|
1663
|
+
canComment: viewer.role !== "viewer",
|
|
1664
|
+
canSend: viewer.role === "owner",
|
|
1665
|
+
admin: viewer.role === "owner"
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1669
|
+
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1670
|
+
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1671
|
+
function ownHosts(host, port) {
|
|
1672
|
+
const named = host === "0.0.0.0" || host === "::" ? [] : [host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`];
|
|
1673
|
+
return [.../* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...named])];
|
|
1674
|
+
}
|
|
1675
|
+
function localOwner() {
|
|
1676
|
+
let name = "You";
|
|
1677
|
+
try {
|
|
1678
|
+
name = os3.userInfo().username.trim() || name;
|
|
1679
|
+
} catch {
|
|
1680
|
+
}
|
|
1681
|
+
return { id: "local", name, email: `${name}@localhost` };
|
|
1682
|
+
}
|
|
1683
|
+
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash2("sha256").update(path5.resolve(root)).digest("hex").slice(0, 12)}`;
|
|
1684
|
+
var ShareHost = class {
|
|
1685
|
+
sharing;
|
|
1686
|
+
options;
|
|
1687
|
+
/** The port the server listens on (set by `listening`); the LAN listeners take the same one. */
|
|
1688
|
+
port = 0;
|
|
1689
|
+
hosts = /* @__PURE__ */ new Set();
|
|
1690
|
+
/** One listener per LAN address while the artifact is open to the network. */
|
|
1691
|
+
lanServers = /* @__PURE__ */ new Map();
|
|
1692
|
+
live = /* @__PURE__ */ new Set();
|
|
1693
|
+
constructor(options) {
|
|
1694
|
+
this.options = options;
|
|
1695
|
+
this.sharing = new Sharing(options.root);
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Starts `server` on the first free port from `port` (a shared artifact tries the port its links were given out with
|
|
1699
|
+
* first, so they keep working, unless the port was given), shares it on the network with `lan` (or as it was left)
|
|
1700
|
+
* and answers the port taken. If this throws, the caller closes what it started.
|
|
1701
|
+
*/
|
|
1702
|
+
async listen(server2, { port, host, lan = false, portGiven = false }) {
|
|
1703
|
+
const { state } = this.sharing;
|
|
1704
|
+
const linksPort = !portGiven && (lan || state.lan) && state.port ? state.port : void 0;
|
|
1705
|
+
const taken = await listenFree(server2, port, host, linksPort);
|
|
1706
|
+
if (linksPort && taken !== linksPort) this.options.log(`port ${linksPort} (the shared links') is in use; took ${taken}: give the new links out again`);
|
|
1707
|
+
else if (!linksPort && port && taken !== port) this.options.log(`port ${port} is in use; took ${taken}`);
|
|
1708
|
+
if (lan && !this.sharing.state.lan) this.sharing.setLan(true, taken);
|
|
1709
|
+
await this.listening(taken, ownHosts(host, taken));
|
|
1710
|
+
return taken;
|
|
1711
|
+
}
|
|
1712
|
+
/** The artifact at each LAN address it is shared on ([] while it is not). */
|
|
1713
|
+
lanUrls() {
|
|
1714
|
+
return [...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`);
|
|
1715
|
+
}
|
|
1716
|
+
/** The main listener is up on `port` (answering `hosts`): open to the network now if the artifact was left shared. */
|
|
1717
|
+
async listening(port, hosts) {
|
|
1718
|
+
this.port = port;
|
|
1719
|
+
this.hosts = new Set(hosts);
|
|
1720
|
+
if (this.sharing.state.lan) await this.setLan(true);
|
|
1721
|
+
}
|
|
1722
|
+
/** Open to the local network now. */
|
|
1723
|
+
get shared() {
|
|
1724
|
+
return this.lanServers.size > 0;
|
|
1725
|
+
}
|
|
1726
|
+
/** A Host header this server answers: its own names, and while shared, its LAN addresses and machine name. */
|
|
1727
|
+
hostAllowed(request) {
|
|
1728
|
+
const host = request.headers.host ?? "";
|
|
1729
|
+
if (this.hosts.has(host)) return true;
|
|
1730
|
+
if (!this.lanServers.size) return false;
|
|
1731
|
+
const suffix = `:${this.port}`;
|
|
1732
|
+
if (!host.endsWith(suffix)) return false;
|
|
1733
|
+
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
1734
|
+
const machine = os3.hostname().toLowerCase();
|
|
1735
|
+
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Opens the artifact to the local network (a listener on each LAN address, on the server's port) or closes it.
|
|
1739
|
+
* Returns the addresses it listens on. Guests already in keep their place for when it opens again.
|
|
1740
|
+
*/
|
|
1741
|
+
async setLan(on) {
|
|
1742
|
+
if (!on) {
|
|
1743
|
+
this.closeLan();
|
|
1744
|
+
for (const live of [...this.live]) if (live.viewer.guest) live.close("unshared");
|
|
1745
|
+
if (this.sharing.state.lan) this.sharing.setLan(false);
|
|
1746
|
+
return [];
|
|
1747
|
+
}
|
|
1748
|
+
for (const address of lanAddresses()) {
|
|
1749
|
+
if (this.lanServers.has(address)) continue;
|
|
1750
|
+
const server2 = this.options.createServer();
|
|
1751
|
+
try {
|
|
1752
|
+
await listen(server2, this.port, address);
|
|
1753
|
+
this.lanServers.set(address, server2);
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
server2.close();
|
|
1756
|
+
this.options.log(`not listening on ${address}:${this.port}: ${error.message}`);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
if (!this.sharing.state.lan || this.sharing.state.port !== this.port) this.sharing.setLan(true, this.port);
|
|
1760
|
+
this.options.log(`shared on the local network: ${[...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`).join(", ") || "(no LAN address)"}`);
|
|
1761
|
+
return [...this.lanServers.keys()];
|
|
1762
|
+
}
|
|
1763
|
+
closeLan() {
|
|
1764
|
+
for (const server2 of this.lanServers.values()) {
|
|
1765
|
+
server2.close();
|
|
1766
|
+
server2.closeAllConnections?.();
|
|
1767
|
+
}
|
|
1768
|
+
this.lanServers.clear();
|
|
1769
|
+
}
|
|
1770
|
+
/** Stops the LAN listeners (the server closes its own). */
|
|
1771
|
+
close() {
|
|
1772
|
+
this.closeLan();
|
|
1773
|
+
}
|
|
1774
|
+
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
1775
|
+
cookie(request) {
|
|
1776
|
+
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
1777
|
+
const eq = part.indexOf("=");
|
|
1778
|
+
if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
|
|
1779
|
+
}
|
|
1780
|
+
return void 0;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
|
|
1784
|
+
* shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
|
|
1785
|
+
* their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
|
|
1786
|
+
* what a role sees).
|
|
1787
|
+
*/
|
|
1788
|
+
viewerOf(request) {
|
|
1789
|
+
if (isLoopback(request.socket.remoteAddress)) return { role: "owner", user: this.options.owner };
|
|
1790
|
+
const guest = this.lanServers.size ? this.sharing.guest(this.cookie(request)) : null;
|
|
1791
|
+
if (!guest) return null;
|
|
1792
|
+
this.sharing.seen(guest);
|
|
1793
|
+
return { role: guest.role, user: guestUser(guest), guest };
|
|
1794
|
+
}
|
|
1795
|
+
/** Keeps track of a stream or socket `viewer` holds open; call the returned function when it closes. */
|
|
1796
|
+
attach(viewer, close) {
|
|
1797
|
+
const live = { viewer, close };
|
|
1798
|
+
this.live.add(live);
|
|
1799
|
+
return () => this.live.delete(live);
|
|
1800
|
+
}
|
|
1801
|
+
/** `attach` for an HTTP stream (server-sent events): it ends when its person loses access. */
|
|
1802
|
+
hold(viewer, response) {
|
|
1803
|
+
const detach = this.attach(viewer, () => response.end());
|
|
1804
|
+
response.on("close", detach);
|
|
1805
|
+
}
|
|
1806
|
+
/** Still allowed in: the owner, or a guest whose link is on and who was not removed. */
|
|
1807
|
+
stillIn(viewer) {
|
|
1808
|
+
return !viewer.guest || this.shared && this.sharing.valid(viewer.guest);
|
|
1809
|
+
}
|
|
1810
|
+
/** Ends the streams of guests who lost access (a link turned off or reset, a guest removed). */
|
|
1811
|
+
dropRevoked() {
|
|
1812
|
+
for (const live of [...this.live]) if (live.viewer.guest && !this.sharing.valid(live.viewer.guest)) live.close("revoked");
|
|
1813
|
+
}
|
|
1814
|
+
me(viewer) {
|
|
1815
|
+
return { role: viewer.role, id: viewer.user.id, name: viewer.user.name, email: viewer.user.email };
|
|
1816
|
+
}
|
|
1817
|
+
// ---- routes ----------------------------------------------------------------------------------------------------
|
|
1818
|
+
/** Answers the share link and /__share paths; false for any other path. */
|
|
1819
|
+
async route(request, response) {
|
|
1820
|
+
const p = new URL(request.url ?? "/", "http://local").pathname;
|
|
1821
|
+
const method = request.method ?? "GET";
|
|
1822
|
+
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1823
|
+
const ours = link || p === "/__share" || p.startsWith("/__share/");
|
|
1824
|
+
if (!ours) return false;
|
|
1825
|
+
if (method === "POST" && !sameOrigin(request)) {
|
|
1826
|
+
request.resume();
|
|
1827
|
+
sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
1828
|
+
return true;
|
|
1829
|
+
}
|
|
1830
|
+
if (link) {
|
|
1831
|
+
if (method === "GET" || method === "POST") await this.join(request, response, method, link[1]);
|
|
1832
|
+
else response.writeHead(405, { Allow: "GET, POST" }).end();
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
const viewer = this.viewerOf(request);
|
|
1836
|
+
if (!viewer) {
|
|
1837
|
+
this.denied(request, response);
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
|
|
1841
|
+
if (method === "GET" && p === "/__share/boot.js") {
|
|
1842
|
+
const body = this.bootScript(viewer, new URL(request.url ?? "/", "http://local").searchParams);
|
|
1843
|
+
response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
|
|
1844
|
+
return true;
|
|
1845
|
+
}
|
|
1846
|
+
if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
|
|
1847
|
+
if (method === "GET" && p === "/__share/ui.js") {
|
|
1848
|
+
fs6.stat(this.options.uiFile, (error, stat) => {
|
|
1849
|
+
if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
|
|
1850
|
+
sendFile(request, response, this.options.uiFile, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
1851
|
+
});
|
|
1852
|
+
return true;
|
|
1853
|
+
}
|
|
1854
|
+
if (viewer.role !== "owner") return sendJson2(response, 403, { code: "not_granted", message: "Only the owner can do this." }), true;
|
|
1855
|
+
if (p === "/__share") return await this.shareApi(request, response, method), true;
|
|
1856
|
+
sendJson2(response, 404, { code: "not_found", message: "Not found" });
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). `query`
|
|
1861
|
+
* ("?…") goes to the boot script, for `globals`.
|
|
1862
|
+
*/
|
|
1863
|
+
headTags(_viewer, query = "") {
|
|
1864
|
+
let tags = `<script src="/__share/boot.js${query.replace(/[<>"']/g, "").replace(/&/g, "&")}"></script>`;
|
|
1865
|
+
if (fs6.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
|
|
1866
|
+
return tags;
|
|
1867
|
+
}
|
|
1868
|
+
/** `html` with the head tags first in its <head>; null when it has none. */
|
|
1869
|
+
injectHead(html, viewer, query = "") {
|
|
1870
|
+
const at = html.search(/<head(\s[^>]*)?>/i);
|
|
1871
|
+
if (at < 0) return null;
|
|
1872
|
+
const end = html.indexOf(">", at) + 1;
|
|
1873
|
+
return html.slice(0, end) + this.headTags(viewer, query) + html.slice(end);
|
|
1874
|
+
}
|
|
1875
|
+
/** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
|
|
1876
|
+
bootScript(viewer, query = new URLSearchParams()) {
|
|
1877
|
+
const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
|
|
1878
|
+
let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
|
|
1879
|
+
for (const [name, value] of Object.entries(this.options.globals?.(viewer, query) ?? {})) js += `window[${script(name)}]=${script(value)};`;
|
|
1880
|
+
if (viewer.guest) js += GUEST_WATCH;
|
|
1881
|
+
return js;
|
|
1882
|
+
}
|
|
1883
|
+
/** Nobody's request: a page for a browser's navigation, JSON for anything else. */
|
|
1884
|
+
denied(request, response) {
|
|
1885
|
+
request.resume();
|
|
1886
|
+
const navigation = (request.method === "GET" || request.method === "HEAD") && /\btext\/html\b/.test(request.headers.accept ?? "");
|
|
1887
|
+
if (!navigation) return sendJson2(response, 403, { code: "not_granted", message: `This ${this.options.noun.en} is not shared with you.` });
|
|
1888
|
+
const t = this.words(request);
|
|
1889
|
+
this.sendPage(response, 403, t.noAccessTitle, `<h1>${esc(t.noAccessTitle)}</h1><p>${esc(t.noAccess)}</p>${this.ownerHint(request, t)}`);
|
|
1890
|
+
}
|
|
1891
|
+
/** A small page of our own (the join form, the refusal), in the reader's language. */
|
|
1892
|
+
sendPage(response, status, title, body, headers = {}) {
|
|
1893
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>${PAGE_CSS}</style></head><body><main>${body}</main></body></html>`;
|
|
1894
|
+
response.writeHead(status, {
|
|
1895
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1896
|
+
"Cache-Control": "no-store",
|
|
1897
|
+
"X-Content-Type-Options": "nosniff",
|
|
1898
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
1899
|
+
...headers
|
|
1900
|
+
}).end(html);
|
|
1901
|
+
}
|
|
1902
|
+
/** On the computer that shares the artifact, over its LAN address: the way back to the owner's page. */
|
|
1903
|
+
ownerHint(request, t) {
|
|
1904
|
+
const from = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
1905
|
+
if (!this.lanServers.has(from)) return "";
|
|
1906
|
+
const url = `http://127.0.0.1:${this.port}/`;
|
|
1907
|
+
return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
|
|
1908
|
+
}
|
|
1909
|
+
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
1910
|
+
async join(request, response, method, token2) {
|
|
1911
|
+
const t = this.words(request);
|
|
1912
|
+
const role = this.sharing.linkRole(token2);
|
|
1913
|
+
const local = isLoopback(request.socket.remoteAddress);
|
|
1914
|
+
const form = method === "POST" ? await readBody(request, 8 << 10).then((body) => body.toString("utf8"), () => "") : "";
|
|
1915
|
+
const title = this.options.title();
|
|
1916
|
+
if (!role || !local && !this.lanServers.size) return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
|
|
1917
|
+
if (local) {
|
|
1918
|
+
const lan = [...this.lanServers.keys()][0];
|
|
1919
|
+
const there = lan ? `http://${lan}:${this.port}/s/${token2}` : "";
|
|
1920
|
+
const tryIt = there ? `<p>${esc(t.tryRole(t.roles[role]))} <a href="${esc(there)}">${esc(there)}</a></p>` : "";
|
|
1921
|
+
return this.sendPage(response, 200, title, `<h1>${esc(title)}</h1><p>${esc(t.youOwn)} <a href="/">${esc(t.openAsOwner)}</a></p>${tryIt}`);
|
|
1922
|
+
}
|
|
1923
|
+
const current = this.sharing.guest(this.cookie(request));
|
|
1924
|
+
if (method === "GET" && current && current.link === this.sharing.state.links[role].token) {
|
|
1925
|
+
response.writeHead(303, { Location: "/", "Cache-Control": "no-store" }).end();
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
if (method === "GET") {
|
|
1929
|
+
return this.sendPage(
|
|
1930
|
+
response,
|
|
1931
|
+
200,
|
|
1932
|
+
title,
|
|
1933
|
+
`<h1>${esc(title)}</h1><p>${esc(t.invited(t.roles[role]))}</p><form method="post"><label for="n">${esc(t.yourName)}</label><input id="n" name="name" maxlength="${MAX_NAME}" value="${esc(current?.name ?? "")}" required autofocus autocomplete="name"><button type="submit">${esc(t.open)}</button></form><p class="note">${esc(t.nameNote)}</p>`
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
const name = new URLSearchParams(form).get("name") ?? "";
|
|
1937
|
+
const guest = this.sharing.join(role, name);
|
|
1938
|
+
this.options.log(`share: ${guest.name} joined as ${role} (${request.socket.remoteAddress})`);
|
|
1939
|
+
const cookie = `${this.options.cookieName}=${guest.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
1940
|
+
response.writeHead(303, { Location: "/", "Set-Cookie": cookie, "Cache-Control": "no-store" }).end();
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* The links this person may pass on: their own role's and the ones below it that are on (the owner: every one on), at
|
|
1944
|
+
* the address they came by (`host`, a Host header hostAllowed let in). A guest could forward the link they joined
|
|
1945
|
+
* with anyway; a lower one gives no more than that. Who joins through one shows in the owner's panel like anyone else.
|
|
1946
|
+
*/
|
|
1947
|
+
invites(viewer, host) {
|
|
1948
|
+
const { links } = this.sharing.state;
|
|
1949
|
+
const from = viewer.role === "owner" ? 0 : GUEST_ROLES.indexOf(viewer.role);
|
|
1950
|
+
return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}/s/${links[role].token}` })) };
|
|
1951
|
+
}
|
|
1952
|
+
/** Links, addresses and people, for the share panel and `artifact.mjs share`. */
|
|
1953
|
+
shareView() {
|
|
1954
|
+
const addresses = [...this.lanServers.keys()];
|
|
1955
|
+
const base = addresses.length ? `http://${addresses[0]}:${this.port}` : `http://127.0.0.1:${this.port}`;
|
|
1956
|
+
const online = /* @__PURE__ */ new Set();
|
|
1957
|
+
for (const live of this.live) if (live.viewer.guest) online.add(live.viewer.guest.id);
|
|
1958
|
+
const { links, guests } = this.sharing.state;
|
|
1959
|
+
return {
|
|
1960
|
+
title: this.options.title(),
|
|
1961
|
+
lan: this.lanServers.size > 0,
|
|
1962
|
+
port: this.port,
|
|
1963
|
+
addresses,
|
|
1964
|
+
local: `http://127.0.0.1:${this.port}/`,
|
|
1965
|
+
links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}/s/${links[role].token}` }])),
|
|
1966
|
+
guests: guests.filter((g) => this.sharing.valid(g) || links[g.role].token === g.link).map((g) => ({ id: g.id, name: g.name, role: g.role, joined: g.joined, ...g.seen ? { seen: g.seen } : {}, online: online.has(g.id), active: this.sharing.valid(g) }))
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
|
|
1971
|
+
* who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
|
|
1972
|
+
* {guest}. Answers the view after.
|
|
1973
|
+
*/
|
|
1974
|
+
async share(action) {
|
|
1975
|
+
const role = action.role;
|
|
1976
|
+
switch (action.action) {
|
|
1977
|
+
case "lan": {
|
|
1978
|
+
const addresses = await this.setLan(action.on === true);
|
|
1979
|
+
if (action.on === true && !addresses.length) throw new ShareError("This computer has no local network address to share on.");
|
|
1980
|
+
break;
|
|
1981
|
+
}
|
|
1982
|
+
case "link":
|
|
1983
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1984
|
+
this.sharing.setLink(role, action.on === true);
|
|
1985
|
+
break;
|
|
1986
|
+
case "reset":
|
|
1987
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1988
|
+
this.sharing.resetLink(role);
|
|
1989
|
+
break;
|
|
1990
|
+
case "role": {
|
|
1991
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1992
|
+
if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
|
|
1993
|
+
if (!this.sharing.setRole(action.guest, role)) throw new ShareError(`The ${role} link is off: turn it on first.`);
|
|
1994
|
+
for (const live of [...this.live]) if (live.viewer.guest?.id === action.guest) live.close("revoked");
|
|
1995
|
+
break;
|
|
1996
|
+
}
|
|
1997
|
+
case "remove":
|
|
1998
|
+
if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
|
|
1999
|
+
break;
|
|
2000
|
+
default:
|
|
2001
|
+
throw new ShareError("action: lan, link, reset, role or remove");
|
|
2002
|
+
}
|
|
2003
|
+
this.options.log(`share: ${JSON.stringify(action)}`);
|
|
2004
|
+
this.dropRevoked();
|
|
2005
|
+
return this.shareView();
|
|
2006
|
+
}
|
|
2007
|
+
async shareApi(request, response, method) {
|
|
2008
|
+
if (method === "GET") return sendJson2(response, 200, this.shareView());
|
|
2009
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: `${method} is not allowed here.` });
|
|
2010
|
+
let action;
|
|
2011
|
+
try {
|
|
2012
|
+
action = JSON.parse((await readBody(request, 64 << 10)).toString("utf8"));
|
|
2013
|
+
} catch {
|
|
2014
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
|
|
2015
|
+
}
|
|
2016
|
+
if (!isRecord4(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
|
|
2017
|
+
try {
|
|
2018
|
+
return sendJson2(response, 200, await this.share(action));
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
if (error instanceof ShareError) return sendJson2(response, 400, { code: "invalid_argument", message: error.message });
|
|
2021
|
+
throw error;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
words(request) {
|
|
2025
|
+
const zh = /^zh\b/i.test(request.headers["accept-language"] ?? "");
|
|
2026
|
+
return (zh ? ZH : EN)(this.options.noun);
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
var SECURE_CONTEXT_SHIM = "if(window.crypto&&!crypto.randomUUID)crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16)),h='',i;b[6]=b[6]&15|64;b[8]=b[8]&63|128;for(i=0;i<16;i++){h+=(b[i]+256).toString(16).slice(1);if(i==3||i==5||i==7||i==9)h+='-'}return h};";
|
|
2030
|
+
var GUEST_WATCH = "setInterval(function(){fetch('/__share/me',{cache:'no-store'}).then(function(r){if(r.status===403)return location.reload();return r.json().then(function(m){if(m&&m.role&&m.role!==window.__baoyuShare.role)location.reload()})},function(){})},5000);";
|
|
2031
|
+
var PAGE_CSS = `
|
|
2032
|
+
:root { color-scheme: light dark; --bg: #f7f6f3; --card: #fff; --fg: #1f1e1d; --muted: #6b6a66; --line: #e3e1dc; --accent: #c96442; }
|
|
2033
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #1f1e1d; --card: #2a2927; --fg: #f2f1ee; --muted: #a8a6a0; --line: #3a3936; } }
|
|
2034
|
+
* { box-sizing: border-box; }
|
|
2035
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding: 16px; }
|
|
2036
|
+
main { width: min(420px, 100%); background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 28px; }
|
|
2037
|
+
h1 { font-size: 20px; margin: 0 0 8px; word-break: break-word; }
|
|
2038
|
+
p { margin: 0 0 16px; color: var(--muted); }
|
|
2039
|
+
label { display: block; font-size: 13px; margin: 0 0 6px; color: var(--muted); }
|
|
2040
|
+
input { width: 100%; font: inherit; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit; margin: 0 0 14px; }
|
|
2041
|
+
button { width: 100%; font: inherit; font-weight: 600; padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
|
|
2042
|
+
.note { font-size: 12px; margin: 14px 0 0; }
|
|
2043
|
+
a { color: var(--accent); overflow-wrap: anywhere; }
|
|
2044
|
+
`;
|
|
2045
|
+
var EN = (noun) => ({
|
|
2046
|
+
noAccessTitle: "No access",
|
|
2047
|
+
noAccess: `This ${noun.en} is not shared with you. Ask its owner for a link.`,
|
|
2048
|
+
linkGoneTitle: "This link does not work",
|
|
2049
|
+
linkGone: `The link was turned off or replaced, or the ${noun.en} is no longer shared. Ask its owner for a new one.`,
|
|
2050
|
+
invited: (what) => `You were invited to ${what} this ${noun.en}.`,
|
|
2051
|
+
yourName: "Your name (others see it on your comments and edits)",
|
|
2052
|
+
open: `Open the ${noun.en}`,
|
|
2053
|
+
nameNote: "This browser keeps your place; the owner can remove it at any time.",
|
|
2054
|
+
roles: { editor: "edit", commenter: "comment on", viewer: "view" },
|
|
2055
|
+
youOwn: `This is your ${noun.en}: on this computer you are its owner.`,
|
|
2056
|
+
openAsOwner: "Open it",
|
|
2057
|
+
tryRole: (what) => `To see it as someone invited to ${what} it does, open the link at the network address, here or on another device:`,
|
|
2058
|
+
ownerHere: `You are on the computer that shares this ${noun.en}. Open it as the owner at`
|
|
2059
|
+
});
|
|
2060
|
+
var ZH = (noun) => ({
|
|
2061
|
+
noAccessTitle: "\u65E0\u6743\u8BBF\u95EE",
|
|
2062
|
+
noAccess: `${noun.zhThis}\u6CA1\u6709\u5206\u4EAB\u7ED9\u4F60\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u94FE\u63A5\u3002`,
|
|
2063
|
+
linkGoneTitle: "\u94FE\u63A5\u5DF2\u5931\u6548",
|
|
2064
|
+
linkGone: `\u8FD9\u4E2A\u94FE\u63A5\u5DF2\u88AB\u5173\u95ED\u6216\u91CD\u7F6E\uFF0C\u6216\u8005${noun.zhThis}\u5DF2\u505C\u6B62\u5206\u4EAB\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u65B0\u94FE\u63A5\u3002`,
|
|
2065
|
+
invited: (what) => `\u4F60\u88AB\u9080\u8BF7${what}${noun.zhThis}\u3002`,
|
|
2066
|
+
yourName: "\u4F60\u7684\u540D\u5B57\uFF08\u522B\u4EBA\u4F1A\u5728\u4F60\u7684\u8BC4\u8BBA\u548C\u7F16\u8F91\u4E0A\u770B\u5230\u5B83\uFF09",
|
|
2067
|
+
open: `\u6253\u5F00${noun.zh}`,
|
|
2068
|
+
nameNote: "\u8FD9\u4E2A\u6D4F\u89C8\u5668\u4F1A\u8BB0\u4F4F\u4F60\u7684\u8EAB\u4EFD\uFF1B\u6240\u6709\u8005\u53EF\u4EE5\u968F\u65F6\u79FB\u9664\u3002",
|
|
2069
|
+
roles: { editor: "\u7F16\u8F91", commenter: "\u8BC4\u8BBA", viewer: "\u67E5\u770B" },
|
|
2070
|
+
youOwn: `\u8FD9\u662F\u4F60\u7684${noun.zh}\uFF1A\u5728\u8FD9\u53F0\u7535\u8111\u4E0A\u4F60\u662F\u6240\u6709\u8005\u3002`,
|
|
2071
|
+
openAsOwner: `\u6253\u5F00${noun.zh}`,
|
|
2072
|
+
tryRole: (what) => `\u60F3\u770B\u770B\u53D7\u9080${what}\u7684\u4EBA\u770B\u5230\u4EC0\u4E48\uFF0C\u7528\u5C40\u57DF\u7F51\u5730\u5740\u6253\u5F00\u8FD9\u4E2A\u94FE\u63A5\uFF08\u5728\u8FD9\u53F0\u7535\u8111\u6216\u522B\u7684\u8BBE\u5907\u4E0A\u90FD\u884C\uFF09\uFF1A`,
|
|
2073
|
+
ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
var whoOf = (viewer) => ({ by: "human", user: { id: viewer.user.id, name: viewer.user.name }, role: viewer.role });
|
|
2077
|
+
var MAX_BODY = 64 << 10;
|
|
2078
|
+
var HistoryHost = class {
|
|
2079
|
+
history;
|
|
2080
|
+
options;
|
|
2081
|
+
constructor(options) {
|
|
2082
|
+
this.options = options;
|
|
2083
|
+
this.history = options.history;
|
|
2084
|
+
}
|
|
2085
|
+
/** What the page's boot script tells the panel (window.__baoyuHistory); `preview` on a version's preview page. */
|
|
2086
|
+
bootGlobal(viewer, preview) {
|
|
2087
|
+
const global = { base: this.options.base, canRestore: canEditAs(viewer.role), me: viewer.user.id };
|
|
2088
|
+
const version = preview === void 0 ? void 0 : this.history.version(preview);
|
|
2089
|
+
if (version) {
|
|
2090
|
+
const newest = this.history.versions().at(-1);
|
|
2091
|
+
global.preview = { n: version.n, at: version.at, by: version.by, name: version.user.name, current: version === newest, current_url: this.options.base };
|
|
2092
|
+
}
|
|
2093
|
+
return global;
|
|
2094
|
+
}
|
|
2095
|
+
notices(viewer, label) {
|
|
2096
|
+
const head = this.history.head();
|
|
2097
|
+
const gone = (n) => !head.has(n.path) && this.history.version(n.n)?.changes[n.path] === null;
|
|
2098
|
+
return this.history.noticesFor(viewer.user.id).map((n) => noticeView(n, label, this.history.version(n.lost.n)?.at, gone(n)));
|
|
2099
|
+
}
|
|
2100
|
+
list(viewer) {
|
|
2101
|
+
const label = this.options.labels();
|
|
2102
|
+
const versions = this.history.versions();
|
|
2103
|
+
const changes = this.history.fileChanges();
|
|
2104
|
+
const newest = versions.at(-1);
|
|
2105
|
+
const rows = [...versions].reverse().map((v) => ({
|
|
2106
|
+
n: v.n,
|
|
2107
|
+
first: v.first,
|
|
2108
|
+
at: v.at,
|
|
2109
|
+
by: v.by,
|
|
2110
|
+
name: v.user.name,
|
|
2111
|
+
userId: v.user.id,
|
|
2112
|
+
...v.role ? { role: v.role } : {},
|
|
2113
|
+
...v.restoredFrom !== void 0 ? { restoredFrom: v.restoredFrom } : {},
|
|
2114
|
+
...v.restoredPart ? { restoredPart: true } : {},
|
|
2115
|
+
...v === newest ? { current: true } : {},
|
|
2116
|
+
preview: this.options.previewUrl(v.n),
|
|
2117
|
+
files: (changes.get(v.n) ?? []).map((c) => ({ path: c.path, op: c.op, label: label(c.path) })),
|
|
2118
|
+
...v.overwrote?.length ? { overwrote: v.overwrote.map((o) => ({ path: o.path, label: label(o.path), name: o.user.name, by: o.by, n: o.n, ...o.soft ? { soft: true } : {} })) } : {}
|
|
2119
|
+
}));
|
|
2120
|
+
return { me: viewer.user.id, canRestore: canEditAs(viewer.role), rows, notices: this.notices(viewer, label) };
|
|
2121
|
+
}
|
|
2122
|
+
/** Answers `__history…` (`sub` is what follows it); false for a path it doesn't know. */
|
|
2123
|
+
async handle(request, response, sub, viewer) {
|
|
2124
|
+
const method = request.method ?? "GET";
|
|
2125
|
+
const url = new URL(request.url ?? "/", "http://local");
|
|
2126
|
+
if (method === "POST" && !sameOrigin(request)) {
|
|
2127
|
+
request.resume();
|
|
2128
|
+
sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
if (method === "GET" && (sub === "" || sub === "/")) return sendJson2(response, 200, this.list(viewer)), true;
|
|
2132
|
+
if (sub === "/notices") {
|
|
2133
|
+
if (method === "GET") return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
|
|
2134
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" }), true;
|
|
2135
|
+
const body = await this.body(request, response);
|
|
2136
|
+
if (!body) return true;
|
|
2137
|
+
if (typeof body.dismiss !== "string") return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {dismiss: id}." }), true;
|
|
2138
|
+
this.history.dismiss(viewer.user.id, body.dismiss);
|
|
2139
|
+
return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
|
|
2140
|
+
}
|
|
2141
|
+
const versionRoute = /^\/(\d+)\/(diff|restore)$/.exec(sub);
|
|
2142
|
+
if (versionRoute) {
|
|
2143
|
+
const n = Number(versionRoute[1]);
|
|
2144
|
+
const version = this.history.version(n);
|
|
2145
|
+
if (!version) return sendJson2(response, 404, { code: "not_found", message: `There is no version ${n} (it may be too old to keep).` }), true;
|
|
2146
|
+
if (versionRoute[2] === "diff") {
|
|
2147
|
+
if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
|
|
2148
|
+
const p = url.searchParams.get("path") ?? "";
|
|
2149
|
+
if (!(p in version.changes)) return sendJson2(response, 404, { code: "not_found", message: `Version ${n} did not change ${p}.` }), true;
|
|
2150
|
+
const before = this.history.stateBefore(n).get(p) ?? null;
|
|
2151
|
+
const after = version.changes[p] ?? null;
|
|
2152
|
+
return sendJson2(response, 200, this.diff(p, before, after)), true;
|
|
2153
|
+
}
|
|
2154
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
|
|
2155
|
+
if (!canEditAs(viewer.role)) return sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can bring a version back." }), true;
|
|
2156
|
+
const body = await this.body(request, response);
|
|
2157
|
+
if (!body) return true;
|
|
2158
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : void 0;
|
|
2159
|
+
const changes = this.history.restoreTo(n, paths, this.options.tracked);
|
|
2160
|
+
if (!changes) return sendJson2(response, 409, { code: "unavailable", message: `Version ${n} can't be brought back: a file it holds is missing from the history.` }), true;
|
|
2161
|
+
const made = await this.options.apply(changes, whoOf(viewer), { restoredFrom: n });
|
|
2162
|
+
this.options.log(`history: ${viewer.user.name} brought back version ${n}${paths ? ` (${paths.join(", ")})` : ""}${made ? ` as version ${made.n}` : " (nothing to change)"}`);
|
|
2163
|
+
return sendJson2(response, 200, { version: made?.n ?? null, changed: [...changes.keys()] }), true;
|
|
2164
|
+
}
|
|
2165
|
+
const noticeRoute = /^\/notice\/([0-9a-f]{6,32})\/(diff|recover)$/.exec(sub);
|
|
2166
|
+
if (noticeRoute) {
|
|
2167
|
+
const notice = this.history.notice(noticeRoute[1]);
|
|
2168
|
+
if (!notice || notice.to !== viewer.user.id) return sendJson2(response, 404, { code: "not_found", message: "No such notice." }), true;
|
|
2169
|
+
if (noticeRoute[2] === "diff") {
|
|
2170
|
+
if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
|
|
2171
|
+
const now2 = this.history.head().get(notice.path) ?? null;
|
|
2172
|
+
const nowBytes = now2 === null ? null : this.history.read(now2);
|
|
2173
|
+
const back2 = this.recovered(notice, nowBytes);
|
|
2174
|
+
if (now2 && !nowBytes || !back2) return sendJson2(response, 200, { path: notice.path, label: this.options.labels()(notice.path), missing: true, added: 0, removed: 0, lines: [], now: now2 }), true;
|
|
2175
|
+
return sendJson2(response, 200, { ...this.diffBytes(notice.path, nowBytes, back2.bytes), now: now2 }), true;
|
|
2176
|
+
}
|
|
2177
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
|
|
2178
|
+
if (!canEditAs(viewer.role)) return request.resume(), sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can change the files." }), true;
|
|
2179
|
+
const body = await this.body(request, response);
|
|
2180
|
+
if (!body) return true;
|
|
2181
|
+
const now = this.history.head().get(notice.path) ?? null;
|
|
2182
|
+
if ("now" in body && body.now !== now) {
|
|
2183
|
+
return sendJson2(response, 409, { code: "changed", message: "This changed while you were looking. Compare again." }), true;
|
|
2184
|
+
}
|
|
2185
|
+
const back = this.recovered(notice, now === null ? null : this.history.read(now));
|
|
2186
|
+
if (!back) return sendJson2(response, 409, { code: "unavailable", message: "That content is no longer in the history." }), true;
|
|
2187
|
+
const made = await this.options.apply(/* @__PURE__ */ new Map([[notice.path, back.bytes]]), whoOf(viewer), { restoredFrom: notice.lost.n, ...back.part ? { restoredPart: true } : {} });
|
|
2188
|
+
this.history.dismiss(viewer.user.id, notice.id);
|
|
2189
|
+
this.options.log(`history: ${viewer.user.name} brought back ${notice.lost.user.name}'s ${back.bytes === null ? "deletion of " : ""}${notice.path} (version ${notice.lost.n}${back.part ? ", what was replaced" : ""})`);
|
|
2190
|
+
return sendJson2(response, 200, { version: made?.n ?? null }), true;
|
|
2191
|
+
}
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
/** Before → after of one file, with the versions' names for the panel's header. */
|
|
2195
|
+
diff(p, before, after) {
|
|
2196
|
+
const read = (sha) => sha === null ? null : this.history.read(sha);
|
|
2197
|
+
const a = read(before);
|
|
2198
|
+
const b = read(after);
|
|
2199
|
+
if (before && !a || after && !b) return { path: p, label: this.options.labels()(p), missing: true, added: 0, removed: 0, lines: [] };
|
|
2200
|
+
return this.diffBytes(p, a, b);
|
|
2201
|
+
}
|
|
2202
|
+
diffBytes(p, a, b) {
|
|
2203
|
+
const [x, y] = this.options.diffSides ? this.options.diffSides(p, a, b) : [a, b];
|
|
2204
|
+
return { path: p, label: this.options.labels()(p), ...diffContents(x, y) };
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* What bringing a notice's lost content back writes to its file (`now`: what the file holds): the lost content merged
|
|
2208
|
+
* into it when the notice has a base and the server can merge (`part` when that is not the whole lost content), else
|
|
2209
|
+
* the whole lost content; a deletion deletes it again (bytes null). null: not in the history.
|
|
2210
|
+
*/
|
|
2211
|
+
recovered(notice, now) {
|
|
2212
|
+
if (notice.lost.sha === null) return { bytes: null, part: false };
|
|
2213
|
+
const lost = this.history.read(notice.lost.sha);
|
|
2214
|
+
if (!lost) return null;
|
|
2215
|
+
const base = notice.base ? this.history.read(notice.base) : null;
|
|
2216
|
+
const merged = base && this.options.recover ? this.options.recover(notice.path, base, lost, now) : null;
|
|
2217
|
+
return merged ? { bytes: merged, part: !merged.equals(lost) } : { bytes: lost, part: false };
|
|
2218
|
+
}
|
|
2219
|
+
async body(request, response) {
|
|
2220
|
+
try {
|
|
2221
|
+
const raw = (await readBody(request, MAX_BODY)).toString("utf8");
|
|
2222
|
+
const body = raw.trim() ? JSON.parse(raw) : {};
|
|
2223
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
|
|
2224
|
+
} catch {
|
|
2225
|
+
}
|
|
2226
|
+
sendJson2(response, 400, { code: "invalid_argument", message: "Expected a JSON object." });
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
function noticeView(n, label, lostAt, gone) {
|
|
2231
|
+
return {
|
|
2232
|
+
...lostAt ? { lostAt } : {},
|
|
2233
|
+
id: n.id,
|
|
2234
|
+
kind: n.kind,
|
|
2235
|
+
at: n.at,
|
|
2236
|
+
path: n.path,
|
|
2237
|
+
label: label(n.path),
|
|
2238
|
+
n: n.n,
|
|
2239
|
+
name: n.by.name,
|
|
2240
|
+
by: n.byAuthor,
|
|
2241
|
+
lostName: n.lost.user.name,
|
|
2242
|
+
lostBy: n.lost.by,
|
|
2243
|
+
lostN: n.lost.n,
|
|
2244
|
+
...n.soft ? { soft: true } : {},
|
|
2245
|
+
// Bringing it back puts back only what was replaced (a write with a base); into a deleted file, all of it.
|
|
2246
|
+
...n.base && !gone ? { part: true } : {},
|
|
2247
|
+
...gone ? { removed: true } : {},
|
|
2248
|
+
// What was replaced is a deletion: bringing it back deletes the file again.
|
|
2249
|
+
...n.lost.sha === null ? { deleted: true } : {}
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
var plain = (text) => ({ en: text, zh: text });
|
|
2253
|
+
|
|
2254
|
+
var PEER_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
2255
|
+
function agentWhere(main2, count, label) {
|
|
2256
|
+
const where = main2 ? label(main2) : null;
|
|
2257
|
+
if (!where || count <= 1) return where;
|
|
2258
|
+
return { en: `${where.en} and ${count - 1} more`, zh: `${where.zh} \u7B49 ${count} \u5904` };
|
|
2259
|
+
}
|
|
2260
|
+
var AGENT_COLOR = "#d25f25";
|
|
2261
|
+
var MAX_PEERS = 64;
|
|
2262
|
+
var MAX_PATCH_BYTES = 16 << 10;
|
|
2263
|
+
var MAX_FIELDS = 12;
|
|
2264
|
+
var MAX_PRESENCE_BYTES = 6 << 10;
|
|
2265
|
+
var BROADCAST_MS = 50;
|
|
2266
|
+
var LEAVE_GRACE_MS = 4e3;
|
|
2267
|
+
var AGENT_LINGER_MS = 2e4;
|
|
2268
|
+
var AGENT_PEER = "agent-claude";
|
|
2269
|
+
var PresenceHub = class {
|
|
2270
|
+
peers = /* @__PURE__ */ new Map();
|
|
2271
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2272
|
+
agent = null;
|
|
2273
|
+
timer = null;
|
|
2274
|
+
/** Adds a page's stream; `send` writes one SSE frame to it. Returns what to call when the stream closes. */
|
|
2275
|
+
join(peer, viewer, send) {
|
|
2276
|
+
let entry = this.peers.get(peer);
|
|
2277
|
+
if (entry && entry.viewer.user.id !== viewer.user.id) {
|
|
2278
|
+
entry = void 0;
|
|
2279
|
+
peer = "";
|
|
2280
|
+
}
|
|
2281
|
+
if (peer && !entry && this.peers.size < MAX_PEERS) {
|
|
2282
|
+
entry = { viewer, presence: {}, updatedAt: Date.now(), streams: 0 };
|
|
2283
|
+
this.peers.set(peer, entry);
|
|
2284
|
+
}
|
|
2285
|
+
if (entry) {
|
|
2286
|
+
entry.streams += 1;
|
|
2287
|
+
if (entry.leaving) clearTimeout(entry.leaving);
|
|
2288
|
+
entry.leaving = void 0;
|
|
2289
|
+
}
|
|
2290
|
+
this.listeners.add(send);
|
|
2291
|
+
send(this.frame());
|
|
2292
|
+
this.schedule();
|
|
2293
|
+
let open = true;
|
|
2294
|
+
return () => {
|
|
2295
|
+
if (!open) return;
|
|
2296
|
+
open = false;
|
|
2297
|
+
this.listeners.delete(send);
|
|
2298
|
+
if (!entry) return;
|
|
2299
|
+
entry.streams -= 1;
|
|
2300
|
+
if (entry.streams > 0) return;
|
|
2301
|
+
const leaving = entry;
|
|
2302
|
+
leaving.leaving = setTimeout(() => {
|
|
2303
|
+
if (this.peers.get(peer) === leaving && leaving.streams === 0) {
|
|
2304
|
+
this.peers.delete(peer);
|
|
2305
|
+
this.schedule();
|
|
2306
|
+
}
|
|
2307
|
+
}, LEAVE_GRACE_MS);
|
|
2308
|
+
leaving.leaving.unref?.();
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
/** Merges a page's patch into its presence; false when the peer isn't this person's open page or the patch is bad. */
|
|
2312
|
+
update(peer, viewer, patch) {
|
|
2313
|
+
const entry = this.peers.get(peer);
|
|
2314
|
+
if (!entry || entry.viewer.user.id !== viewer.user.id) return false;
|
|
2315
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) return false;
|
|
2316
|
+
const next = { ...entry.presence };
|
|
2317
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2318
|
+
if (value === null || value === void 0) delete next[key];
|
|
2319
|
+
else next[key] = value;
|
|
2320
|
+
}
|
|
2321
|
+
if (Object.keys(next).length > MAX_FIELDS || Buffer.byteLength(JSON.stringify(next)) > MAX_PRESENCE_BYTES) return false;
|
|
2322
|
+
entry.presence = next;
|
|
2323
|
+
entry.updatedAt = Date.now();
|
|
2324
|
+
this.schedule();
|
|
2325
|
+
return true;
|
|
2326
|
+
}
|
|
2327
|
+
/** The agent changed the artifact: it shows on the list (with `context`, where it worked) for a while. */
|
|
2328
|
+
agentActive(agent = {}) {
|
|
2329
|
+
if (this.agent) clearTimeout(this.agent.timer);
|
|
2330
|
+
const presence = { who: { n: agent.name ?? "Agent", c: AGENT_COLOR } };
|
|
2331
|
+
if (agent.context) presence.context = agent.context;
|
|
2332
|
+
if (agent.where) presence.where = agent.where;
|
|
2333
|
+
const timer = setTimeout(() => {
|
|
2334
|
+
this.agent = null;
|
|
2335
|
+
this.schedule();
|
|
2336
|
+
}, AGENT_LINGER_MS);
|
|
2337
|
+
timer.unref?.();
|
|
2338
|
+
this.agent = { presence, updatedAt: Date.now(), timer };
|
|
2339
|
+
this.schedule();
|
|
2340
|
+
}
|
|
2341
|
+
/** Everyone on the list now. */
|
|
2342
|
+
list() {
|
|
2343
|
+
const views = [];
|
|
2344
|
+
for (const [peer, entry] of this.peers) {
|
|
2345
|
+
const who = entry.presence.who;
|
|
2346
|
+
const presence = { ...entry.presence, who: { ...typeof who === "object" && who !== null && !Array.isArray(who) ? who : {}, n: entry.viewer.user.name } };
|
|
2347
|
+
views.push({ peer, by: entry.viewer.user.id, name: entry.viewer.user.name, role: entry.viewer.role, presence, updatedAt: entry.updatedAt });
|
|
2348
|
+
}
|
|
2349
|
+
if (this.agent) {
|
|
2350
|
+
const name = String(this.agent.presence.who.n ?? "Agent");
|
|
2351
|
+
views.push({ peer: AGENT_PEER, by: "agent", name, kind: "agent", presence: this.agent.presence, updatedAt: this.agent.updatedAt });
|
|
2352
|
+
}
|
|
2353
|
+
return views;
|
|
2354
|
+
}
|
|
2355
|
+
/** Answers POST `__presence` ({peer, patch}); GET gives the list. */
|
|
2356
|
+
async handle(request, response, viewer) {
|
|
2357
|
+
const method = request.method ?? "GET";
|
|
2358
|
+
if (method === "GET") return sendJson2(response, 200, { peers: this.list() });
|
|
2359
|
+
if (method !== "POST") {
|
|
2360
|
+
request.resume();
|
|
2361
|
+
return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" });
|
|
2362
|
+
}
|
|
2363
|
+
if (!sameOrigin(request)) {
|
|
2364
|
+
request.resume();
|
|
2365
|
+
return sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
2366
|
+
}
|
|
2367
|
+
let body;
|
|
2368
|
+
try {
|
|
2369
|
+
body = JSON.parse((await readBody(request, MAX_PATCH_BYTES)).toString("utf8"));
|
|
2370
|
+
} catch {
|
|
2371
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: `Expected {peer, patch} under ${MAX_PATCH_BYTES} bytes.` });
|
|
2372
|
+
}
|
|
2373
|
+
const { peer, patch } = typeof body === "object" && body !== null ? body : {};
|
|
2374
|
+
if (typeof peer !== "string" || !PEER_ID.test(peer)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {peer, patch}." });
|
|
2375
|
+
if (!this.update(peer, viewer, patch)) return sendJson2(response, 409, { code: "not_joined", message: "That page is not open here (its event stream is closed)." });
|
|
2376
|
+
response.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
2377
|
+
}
|
|
2378
|
+
/** Ends the timers (the server is closing). */
|
|
2379
|
+
close() {
|
|
2380
|
+
if (this.timer) clearTimeout(this.timer);
|
|
2381
|
+
if (this.agent) clearTimeout(this.agent.timer);
|
|
2382
|
+
for (const entry of this.peers.values()) if (entry.leaving) clearTimeout(entry.leaving);
|
|
2383
|
+
this.peers.clear();
|
|
2384
|
+
this.listeners.clear();
|
|
2385
|
+
}
|
|
2386
|
+
frame() {
|
|
2387
|
+
return `event: peers
|
|
2388
|
+
data: ${JSON.stringify({ peers: this.list() })}
|
|
2389
|
+
|
|
2390
|
+
`;
|
|
2391
|
+
}
|
|
2392
|
+
schedule() {
|
|
2393
|
+
if (this.timer) return;
|
|
2394
|
+
this.timer = setTimeout(() => {
|
|
2395
|
+
this.timer = null;
|
|
2396
|
+
const frame = this.frame();
|
|
2397
|
+
for (const send of this.listeners) send(frame);
|
|
2398
|
+
}, BROADCAST_MS);
|
|
2399
|
+
this.timer.unref?.();
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
2402
|
+
|
|
816
2403
|
var KIND = "";
|
|
817
2404
|
var NOUN = "";
|
|
818
2405
|
var INDEX_FILE = "";
|
|
@@ -820,6 +2407,8 @@ var LAYOUT;
|
|
|
820
2407
|
var pageScriptSrc;
|
|
821
2408
|
var sessionExtras;
|
|
822
2409
|
var commentLocation;
|
|
2410
|
+
var kindLabels;
|
|
2411
|
+
var kindAgentContext;
|
|
823
2412
|
var SCRIPT = "";
|
|
824
2413
|
var EDITOR_DIR = "";
|
|
825
2414
|
var MIME = {
|
|
@@ -893,6 +2482,9 @@ var ROUTES = {
|
|
|
893
2482
|
session: "__deck/session"
|
|
894
2483
|
};
|
|
895
2484
|
var COMMENTS_ROUTE = "__comments";
|
|
2485
|
+
var HISTORY_ROUTE = "__history";
|
|
2486
|
+
var PRESENCE_ROUTE = "__presence";
|
|
2487
|
+
var PREVIEW_PATH = /^v\/(\d{1,9})(\/.*)?$/;
|
|
896
2488
|
var ASSET_INDEX = "index.json";
|
|
897
2489
|
var BLOB_ID = /^[0-9a-f]{32}$/;
|
|
898
2490
|
var BLOB_PATH = /^_blob\/([0-9a-f]{32})$/;
|
|
@@ -903,7 +2495,11 @@ var SSE_KEEPALIVE_MS2 = 25e3;
|
|
|
903
2495
|
var WATCH_DEBOUNCE_MS2 = 300;
|
|
904
2496
|
var POLL_MS2 = 2e3;
|
|
905
2497
|
var CHANGE_LOG_SIZE = 64;
|
|
906
|
-
var
|
|
2498
|
+
var OVERWRITE_WINDOW_MS = 6e4;
|
|
2499
|
+
var filesOf = (listing, paths) => Object.fromEntries(paths.map((p) => [p, listing.files[p]?.sha256 ?? null]));
|
|
2500
|
+
var authorOf = (who) => ({ id: who.user.id, name: who.user.name, ...who.by === "agent" ? { agent: true } : {} });
|
|
2501
|
+
var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
2502
|
+
var sha2562 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
|
|
907
2503
|
function contentTypeOf(file) {
|
|
908
2504
|
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
909
2505
|
return MIME[ext] ?? "application/octet-stream";
|
|
@@ -913,6 +2509,7 @@ function isProjectPath(p) {
|
|
|
913
2509
|
if (typeof p !== "string" || !p.startsWith("project/") || p.includes("\\") || p.includes("\0")) return false;
|
|
914
2510
|
return p.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
915
2511
|
}
|
|
2512
|
+
var SHA256 = /^[0-9a-f]{64}$/;
|
|
916
2513
|
var isWriteEntry = (entry) => entry !== null && !("delete" in entry);
|
|
917
2514
|
function isWireEntry(value) {
|
|
918
2515
|
if (value === null) return true;
|
|
@@ -926,7 +2523,7 @@ function inlineScriptHashes(html) {
|
|
|
926
2523
|
const hashes = [];
|
|
927
2524
|
for (const match of html.matchAll(/<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi)) {
|
|
928
2525
|
if (/\ssrc\s*=/i.test(match[1] ?? "")) continue;
|
|
929
|
-
hashes.push("sha256-" +
|
|
2526
|
+
hashes.push("sha256-" + createHash3("sha256").update(match[2] ?? "", "utf8").digest("base64"));
|
|
930
2527
|
}
|
|
931
2528
|
return hashes;
|
|
932
2529
|
}
|
|
@@ -948,7 +2545,7 @@ function pageCsp(scriptSrc) {
|
|
|
948
2545
|
].join("; ");
|
|
949
2546
|
}
|
|
950
2547
|
function versionOf(files) {
|
|
951
|
-
const digest =
|
|
2548
|
+
const digest = sha2562(
|
|
952
2549
|
Object.entries(files).map(([p, f]) => `${p}:${f.sha256}`).join("\n")
|
|
953
2550
|
);
|
|
954
2551
|
return "local-" + digest.slice(0, 16);
|
|
@@ -959,7 +2556,7 @@ async function collectFiles(root, limit) {
|
|
|
959
2556
|
const seen = /* @__PURE__ */ new Set();
|
|
960
2557
|
let truncated = false;
|
|
961
2558
|
const visit = async (relDir, descend) => {
|
|
962
|
-
const dir = relDir === "" ? root :
|
|
2559
|
+
const dir = relDir === "" ? root : path6.join(root, ...relDir.split("/"));
|
|
963
2560
|
let entries;
|
|
964
2561
|
try {
|
|
965
2562
|
entries = await fsp2.readdir(dir, { withFileTypes: true });
|
|
@@ -990,8 +2587,8 @@ async function collectFiles(root, limit) {
|
|
|
990
2587
|
return { rels, truncated };
|
|
991
2588
|
}
|
|
992
2589
|
async function writeFileAtomic(file, bytes) {
|
|
993
|
-
await fsp2.mkdir(
|
|
994
|
-
const tmp =
|
|
2590
|
+
await fsp2.mkdir(path6.dirname(file), { recursive: true });
|
|
2591
|
+
const tmp = path6.join(path6.dirname(file), `.${path6.basename(file)}.${randomUUID()}.tmp`);
|
|
995
2592
|
try {
|
|
996
2593
|
await fsp2.writeFile(tmp, bytes);
|
|
997
2594
|
await fsp2.rename(tmp, file);
|
|
@@ -1003,19 +2600,27 @@ async function writeFileAtomic(file, bytes) {
|
|
|
1003
2600
|
}
|
|
1004
2601
|
var Store = class {
|
|
1005
2602
|
root;
|
|
2603
|
+
history;
|
|
1006
2604
|
log;
|
|
1007
2605
|
/** project/<path> → sha256 of the page's last save not yet seen by the watcher ('' for a delete). */
|
|
1008
2606
|
ownWrites;
|
|
2607
|
+
/** "<user id>\0project/<path>" → when the server last refused that person's save of the file as a conflict. */
|
|
2608
|
+
refused;
|
|
1009
2609
|
queue;
|
|
1010
2610
|
seq;
|
|
1011
2611
|
truncationReported;
|
|
1012
|
-
|
|
2612
|
+
/** The last listing left files out (more than the format's limit): its missing files are not deletes. */
|
|
2613
|
+
truncated;
|
|
2614
|
+
constructor(root, history, log) {
|
|
1013
2615
|
this.root = root;
|
|
2616
|
+
this.history = history;
|
|
1014
2617
|
this.log = log;
|
|
1015
2618
|
this.ownWrites = /* @__PURE__ */ new Map();
|
|
2619
|
+
this.refused = /* @__PURE__ */ new Map();
|
|
1016
2620
|
this.queue = Promise.resolve();
|
|
1017
2621
|
this.seq = 0;
|
|
1018
2622
|
this.truncationReported = false;
|
|
2623
|
+
this.truncated = false;
|
|
1019
2624
|
}
|
|
1020
2625
|
/** Run `work` after every save, upload and change check before it, and before the next. */
|
|
1021
2626
|
exclusive(work) {
|
|
@@ -1033,11 +2638,11 @@ var Store = class {
|
|
|
1033
2638
|
async diskPath(rel) {
|
|
1034
2639
|
const realRoot = await fsp2.realpath(this.root).catch(() => null);
|
|
1035
2640
|
if (realRoot === null) return null;
|
|
1036
|
-
const file =
|
|
1037
|
-
for (let dir =
|
|
2641
|
+
const file = path6.join(realRoot, ...rel.split("/"));
|
|
2642
|
+
for (let dir = path6.dirname(file); ; dir = path6.dirname(dir)) {
|
|
1038
2643
|
const real = await fsp2.realpath(dir).catch(() => null);
|
|
1039
2644
|
if (real === null) {
|
|
1040
|
-
if (
|
|
2645
|
+
if (path6.dirname(dir) === dir) return null;
|
|
1041
2646
|
continue;
|
|
1042
2647
|
}
|
|
1043
2648
|
if (real !== dir) return null;
|
|
@@ -1049,6 +2654,7 @@ var Store = class {
|
|
|
1049
2654
|
async listing() {
|
|
1050
2655
|
const limit = LAYOUT.maxFiles;
|
|
1051
2656
|
const { rels, truncated } = await collectFiles(this.root, limit);
|
|
2657
|
+
this.truncated = truncated;
|
|
1052
2658
|
if (truncated && !this.truncationReported) {
|
|
1053
2659
|
this.truncationReported = true;
|
|
1054
2660
|
this.log(`The ${NOUN} folder has more than ${limit} files; the editor lists only the first ${limit}: ${this.root}`);
|
|
@@ -1059,7 +2665,7 @@ var Store = class {
|
|
|
1059
2665
|
if (file === null) continue;
|
|
1060
2666
|
const bytes = await fsp2.readFile(file).catch(() => null);
|
|
1061
2667
|
if (bytes === null) continue;
|
|
1062
|
-
files[`project/${rel}`] = { sha256:
|
|
2668
|
+
files[`project/${rel}`] = { sha256: sha2562(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
|
|
1063
2669
|
}
|
|
1064
2670
|
return { ver: versionOf(files), files };
|
|
1065
2671
|
}
|
|
@@ -1069,11 +2675,14 @@ var Store = class {
|
|
|
1069
2675
|
if (file === null) return null;
|
|
1070
2676
|
return fsp2.readFile(file).catch(() => null);
|
|
1071
2677
|
}
|
|
1072
|
-
/**
|
|
1073
|
-
|
|
1074
|
-
|
|
2678
|
+
/**
|
|
2679
|
+
* A save from the page by `who`: all entries or none (ifMatch is checked for all before anything is written). It is
|
|
2680
|
+
* a version in the history under their name; what changed on disk before it is one of its own, Claude's.
|
|
2681
|
+
*/
|
|
2682
|
+
publish(batch, who, over = [], merged = []) {
|
|
2683
|
+
return this.exclusive(() => this.publishNow(batch, who, over, merged));
|
|
1075
2684
|
}
|
|
1076
|
-
async publishNow(batch) {
|
|
2685
|
+
async publishNow(batch, who, over, merged) {
|
|
1077
2686
|
const files = /* @__PURE__ */ new Map();
|
|
1078
2687
|
let total = 0;
|
|
1079
2688
|
for (const [p, entry] of Object.entries(batch)) {
|
|
@@ -1092,6 +2701,7 @@ var Store = class {
|
|
|
1092
2701
|
return { status: 413, body: { code: "too_large", message: `a publish can write at most ${LAYOUT.maxPublishBytes} bytes` } };
|
|
1093
2702
|
}
|
|
1094
2703
|
const before = await this.listing();
|
|
2704
|
+
await this.syncDisk(before);
|
|
1095
2705
|
const conflicts = [];
|
|
1096
2706
|
for (const [p, entry] of Object.entries(batch)) {
|
|
1097
2707
|
const expected = entry?.ifMatch;
|
|
@@ -1099,24 +2709,121 @@ var Store = class {
|
|
|
1099
2709
|
const actual = before.files[p]?.sha256 ?? null;
|
|
1100
2710
|
if (actual !== expected) conflicts.push({ path: p, actual, expected });
|
|
1101
2711
|
}
|
|
1102
|
-
if (conflicts.length > 0)
|
|
2712
|
+
if (conflicts.length > 0) {
|
|
2713
|
+
const now = Date.now();
|
|
2714
|
+
for (const c of conflicts) this.refused.set(`${who.user.id}\0${c.path}`, now);
|
|
2715
|
+
return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
|
|
2716
|
+
}
|
|
2717
|
+
for (const m of merged) {
|
|
2718
|
+
if (batch[m.path] !== void 0 && before.files[m.path]?.sha256 === m.sha) this.refused.delete(`${who.user.id}\0${m.path}`);
|
|
2719
|
+
}
|
|
2720
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
2721
|
+
for (const o of over) {
|
|
2722
|
+
if (batch[o.path] === void 0 || before.files[o.path]?.sha256 !== o.sha) continue;
|
|
2723
|
+
this.refused.set(`${who.user.id}\0${o.path}`, Date.now());
|
|
2724
|
+
chosen.set(o.path, o.base);
|
|
2725
|
+
}
|
|
2726
|
+
const overwrote = this.overwrites(batch, before, who, chosen);
|
|
1103
2727
|
const shas = {};
|
|
2728
|
+
const written = /* @__PURE__ */ new Map();
|
|
1104
2729
|
for (const [p, entry] of Object.entries(batch)) {
|
|
1105
2730
|
const file = files.get(p);
|
|
1106
2731
|
if (!isWriteEntry(entry)) {
|
|
1107
2732
|
this.ownWrites.set(p, "");
|
|
1108
2733
|
await fsp2.rm(file, { force: true });
|
|
2734
|
+
written.set(p, null);
|
|
1109
2735
|
continue;
|
|
1110
2736
|
}
|
|
1111
2737
|
const bytes = Buffer.from(entry.base64, "base64");
|
|
1112
|
-
const sha =
|
|
2738
|
+
const sha = sha2562(bytes);
|
|
1113
2739
|
this.ownWrites.set(p, sha);
|
|
1114
2740
|
await writeFileAtomic(file, bytes);
|
|
1115
2741
|
shas[p] = sha;
|
|
2742
|
+
written.set(p, bytes);
|
|
1116
2743
|
}
|
|
2744
|
+
const version = this.history.record(written, who, overwrote.length ? { overwrote } : {});
|
|
1117
2745
|
const after = await this.listing();
|
|
1118
2746
|
this.seq += 1;
|
|
1119
|
-
return { status: 200, body: { version: after.ver, seq: this.seq, shas } };
|
|
2747
|
+
return { status: 200, body: { version: after.ver, seq: this.seq, shas }, version };
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* The files this save replaces without its page having seen them: the server refused this person's save of the file
|
|
2751
|
+
* as a conflict moments ago (someone else had saved it since their page loaded it), and now the page saves on top of
|
|
2752
|
+
* what that someone saved. (The editors take the newer file and save their own over it.) Their own other tab's save,
|
|
2753
|
+
* and the content the history began with, are no one else's. `chosen`: the files the page says its person saw and
|
|
2754
|
+
* chose to save over (marked as refused above), whose overwrite is `chosen`, with the version the edits started
|
|
2755
|
+
* from as its `base` when the history holds that content (and it is not what was lost, which would bring back nothing).
|
|
2756
|
+
* A page that names no base (Keep mine from a bar or a banner, a save on top of a refused one) gets the file as it was
|
|
2757
|
+
* right before the lost version: what both started from when one saved while the other was editing, so Bring back
|
|
2758
|
+
* returns what that version changed and leaves the saver's other changes.
|
|
2759
|
+
*/
|
|
2760
|
+
overwrites(batch, before, who, chosen) {
|
|
2761
|
+
const now = Date.now();
|
|
2762
|
+
const out = [];
|
|
2763
|
+
for (const [p, entry] of Object.entries(batch)) {
|
|
2764
|
+
const key = `${who.user.id}\0${p}`;
|
|
2765
|
+
const at = this.refused.get(key);
|
|
2766
|
+
if (at === void 0) continue;
|
|
2767
|
+
this.refused.delete(key);
|
|
2768
|
+
const lost = before.files[p]?.sha256;
|
|
2769
|
+
if (now - at > OVERWRITE_WINDOW_MS || !lost) continue;
|
|
2770
|
+
if ((isWriteEntry(entry) ? sha2562(Buffer.from(entry.base64, "base64")) : null) === lost) continue;
|
|
2771
|
+
const last = this.history.lastChange(p);
|
|
2772
|
+
if (!last || last.sha !== lost || last.version.by === "system" || last.version.user.id === who.user.id) continue;
|
|
2773
|
+
const base = chosen.get(p) ?? this.history.stateBefore(last.version.n).get(p);
|
|
2774
|
+
out.push({
|
|
2775
|
+
path: p,
|
|
2776
|
+
lost,
|
|
2777
|
+
n: last.version.n,
|
|
2778
|
+
by: last.version.by,
|
|
2779
|
+
user: { ...last.version.user },
|
|
2780
|
+
...chosen.has(p) ? { chosen: true } : {},
|
|
2781
|
+
...base && base !== lost && this.history.size(base) !== null ? { base } : {}
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
for (const [key, at] of this.refused) if (now - at > OVERWRITE_WINDOW_MS) this.refused.delete(key);
|
|
2785
|
+
return out;
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* Records what changed on disk since the history last looked, as Claude's (the agent's, or anything else on this
|
|
2789
|
+
* computer; the files as they were when the history began are a version of no one's). Runs inside `exclusive`.
|
|
2790
|
+
*/
|
|
2791
|
+
async syncDisk(listing) {
|
|
2792
|
+
const head = this.history.head();
|
|
2793
|
+
const changes = /* @__PURE__ */ new Map();
|
|
2794
|
+
for (const [p, f] of Object.entries(listing.files)) {
|
|
2795
|
+
if (head.get(p) === f.sha256) continue;
|
|
2796
|
+
const bytes = await this.readProjectFile(p);
|
|
2797
|
+
if (bytes !== null) changes.set(p, bytes);
|
|
2798
|
+
}
|
|
2799
|
+
if (!this.truncated) {
|
|
2800
|
+
for (const p of head.keys()) if (!(p in listing.files) && this.fileOf(p) !== null) changes.set(p, null);
|
|
2801
|
+
}
|
|
2802
|
+
if (changes.size === 0) return null;
|
|
2803
|
+
const version = this.history.record(changes, this.history.empty ? SYSTEM : AGENT);
|
|
2804
|
+
if (version) this.log(`history: version ${version.n}${version.by === "agent" ? " (changed on disk)" : " (the files as they were)"}: ${[...changes.keys()].map((p) => p.replace(/^project\//, "")).join(", ")}`);
|
|
2805
|
+
return version;
|
|
2806
|
+
}
|
|
2807
|
+
/** Writes contents from the history (a restore) and records them as `who`'s version. */
|
|
2808
|
+
applyFromHistory(changes, who, extra) {
|
|
2809
|
+
return this.exclusive(async () => {
|
|
2810
|
+
await this.syncDisk(await this.listing());
|
|
2811
|
+
const written = /* @__PURE__ */ new Map();
|
|
2812
|
+
for (const [p, bytes] of changes) {
|
|
2813
|
+
const rel = this.fileOf(p);
|
|
2814
|
+
const file = rel === null ? null : await this.diskPath(rel);
|
|
2815
|
+
if (file === null) continue;
|
|
2816
|
+
if (bytes === null) {
|
|
2817
|
+
this.ownWrites.set(p, "");
|
|
2818
|
+
await fsp2.rm(file, { force: true });
|
|
2819
|
+
} else {
|
|
2820
|
+
this.ownWrites.set(p, sha2562(bytes));
|
|
2821
|
+
await writeFileAtomic(file, bytes);
|
|
2822
|
+
}
|
|
2823
|
+
written.set(p, bytes);
|
|
2824
|
+
}
|
|
2825
|
+
return { version: this.history.record(written, who, extra), paths: [...written.keys()] };
|
|
2826
|
+
});
|
|
1120
2827
|
}
|
|
1121
2828
|
async assetIndex() {
|
|
1122
2829
|
try {
|
|
@@ -1133,7 +2840,7 @@ var Store = class {
|
|
|
1133
2840
|
return this.exclusive(() => this.putBlobNow(bytes, contentType));
|
|
1134
2841
|
}
|
|
1135
2842
|
async putBlobNow(bytes, contentType) {
|
|
1136
|
-
const id =
|
|
2843
|
+
const id = sha2562(bytes).slice(0, 32);
|
|
1137
2844
|
const type = (contentType.split(";")[0] ?? "").trim().toLowerCase() || "application/octet-stream";
|
|
1138
2845
|
if (await this.findBlobFile(id) === null) {
|
|
1139
2846
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${id}.${assetExtensionOf(type)}`);
|
|
@@ -1150,7 +2857,7 @@ var Store = class {
|
|
|
1150
2857
|
}
|
|
1151
2858
|
async findBlobFile(id) {
|
|
1152
2859
|
try {
|
|
1153
|
-
const names = await fsp2.readdir(
|
|
2860
|
+
const names = await fsp2.readdir(path6.join(this.root, LAYOUT.blobDir));
|
|
1154
2861
|
const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
|
|
1155
2862
|
if (name === void 0) return null;
|
|
1156
2863
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
|
|
@@ -1177,7 +2884,7 @@ var Store = class {
|
|
|
1177
2884
|
let sha = "";
|
|
1178
2885
|
try {
|
|
1179
2886
|
const file = await this.diskPath(rel);
|
|
1180
|
-
if (file !== null && (await fsp2.lstat(file)).isFile()) sha =
|
|
2887
|
+
if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha2562(await fsp2.readFile(file));
|
|
1181
2888
|
} catch {
|
|
1182
2889
|
sha = "";
|
|
1183
2890
|
}
|
|
@@ -1205,13 +2912,13 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1205
2912
|
const directories = /* @__PURE__ */ new Map();
|
|
1206
2913
|
const visit = (rel, descend) => {
|
|
1207
2914
|
if (directories.size >= maxDirectories) return;
|
|
1208
|
-
const dir = rel === "" ? store.root :
|
|
1209
|
-
if (directories.has(dir) || !
|
|
1210
|
-
const info =
|
|
2915
|
+
const dir = rel === "" ? store.root : path6.join(store.root, ...rel.split("/"));
|
|
2916
|
+
if (directories.has(dir) || !fs7.existsSync(dir)) return;
|
|
2917
|
+
const info = fs7.lstatSync(dir);
|
|
1211
2918
|
if (!info.isDirectory()) return;
|
|
1212
2919
|
directories.set(dir, `${info.dev}:${info.ino}`);
|
|
1213
2920
|
if (!descend) return;
|
|
1214
|
-
for (const entry of
|
|
2921
|
+
for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
|
|
1215
2922
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
1216
2923
|
const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
1217
2924
|
if (LAYOUT.descendInto(child)) visit(child, true);
|
|
@@ -1228,19 +2935,19 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1228
2935
|
if (watchers.has(dir)) continue;
|
|
1229
2936
|
let watcher;
|
|
1230
2937
|
try {
|
|
1231
|
-
watcher =
|
|
2938
|
+
watcher = fs7.watch(dir, (event, name) => {
|
|
1232
2939
|
if (stopped) return;
|
|
1233
2940
|
if (name) {
|
|
1234
|
-
const changed =
|
|
2941
|
+
const changed = path6.join(dir, String(name));
|
|
1235
2942
|
if (event === "rename" && watchers.has(changed)) {
|
|
1236
2943
|
for (const [watchedDir, item] of watchers) {
|
|
1237
|
-
if (watchedDir === changed || watchedDir.startsWith(changed +
|
|
2944
|
+
if (watchedDir === changed || watchedDir.startsWith(changed + path6.sep)) {
|
|
1238
2945
|
item.watcher.close();
|
|
1239
2946
|
watchers.delete(watchedDir);
|
|
1240
2947
|
}
|
|
1241
2948
|
}
|
|
1242
2949
|
}
|
|
1243
|
-
const rel =
|
|
2950
|
+
const rel = path6.relative(store.root, changed).split(path6.sep).join("/");
|
|
1244
2951
|
if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
|
|
1245
2952
|
}
|
|
1246
2953
|
schedule();
|
|
@@ -1258,6 +2965,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1258
2965
|
try {
|
|
1259
2966
|
refresh();
|
|
1260
2967
|
const current = await store.listing();
|
|
2968
|
+
await store.syncDisk(current);
|
|
1261
2969
|
if (seen === null) {
|
|
1262
2970
|
seen = new Map(Object.entries(current.files).map(([p, f]) => [p, f.sha256]));
|
|
1263
2971
|
pollSignature = await signature();
|
|
@@ -1265,7 +2973,8 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1265
2973
|
}
|
|
1266
2974
|
const external = await store.externalChanges(/* @__PURE__ */ new Set([...seen.keys(), ...Object.keys(current.files)]), seen);
|
|
1267
2975
|
if (stopped || external.length === 0) return;
|
|
1268
|
-
|
|
2976
|
+
const after = await store.listing();
|
|
2977
|
+
onChange({ type: "external-change", paths: external, ver: after.ver, files: filesOf(after, external) });
|
|
1269
2978
|
} catch (error) {
|
|
1270
2979
|
onError(error);
|
|
1271
2980
|
}
|
|
@@ -1274,7 +2983,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1274
2983
|
const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
|
|
1275
2984
|
const parts = [];
|
|
1276
2985
|
for (const rel of rels) {
|
|
1277
|
-
const info = await fsp2.lstat(
|
|
2986
|
+
const info = await fsp2.lstat(path6.join(store.root, ...rel.split("/"))).catch(() => null);
|
|
1278
2987
|
parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
|
|
1279
2988
|
}
|
|
1280
2989
|
return parts.join("\n");
|
|
@@ -1303,23 +3012,46 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1303
3012
|
};
|
|
1304
3013
|
}
|
|
1305
3014
|
var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
|
|
1306
|
-
async function startServer({ root, port =
|
|
1307
|
-
`), agentHook }) {
|
|
1308
|
-
const project =
|
|
3015
|
+
async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
|
|
3016
|
+
`), agentHook, lan = false, portGiven = false }) {
|
|
3017
|
+
const project = path6.join(root, "project");
|
|
1309
3018
|
const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
|
|
1310
3019
|
const projectDir = await fsp2.realpath(project);
|
|
1311
|
-
const
|
|
3020
|
+
const history = new History(root);
|
|
3021
|
+
const store = new Store(projectDir, history, stamp);
|
|
3022
|
+
const owner = localOwner();
|
|
3023
|
+
const share = new ShareHost({
|
|
3024
|
+
root,
|
|
3025
|
+
cookieName: guestCookieName(KIND, root),
|
|
3026
|
+
owner,
|
|
3027
|
+
noun: NOUNS[KIND] ?? { en: NOUN, zh: NOUN, zhThis: NOUN },
|
|
3028
|
+
title: () => artifactTitle(root, projectDir),
|
|
3029
|
+
log: stamp,
|
|
3030
|
+
createServer: () => http.createServer(handle),
|
|
3031
|
+
uiFile: path6.join(EDITOR_DIR, "share-ui.js"),
|
|
3032
|
+
// The History panel's address and what this person may do there; on a version's page (?at=n), which version.
|
|
3033
|
+
globals: (viewer, query) => {
|
|
3034
|
+
const at = Number(query.get("at") ?? "");
|
|
3035
|
+
return { __baoyuHistory: historyHost.bootGlobal(viewer, query.has("at") && Number.isInteger(at) ? at : void 0) };
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
1312
3038
|
const comments = createCommentsService({
|
|
1313
3039
|
root,
|
|
1314
3040
|
log: stamp,
|
|
1315
3041
|
locate: commentLocation ? (input) => commentLocation(input, projectDir) : void 0,
|
|
1316
3042
|
agentHook,
|
|
1317
|
-
|
|
3043
|
+
userName: owner.name,
|
|
3044
|
+
allowWrite: sameOrigin,
|
|
3045
|
+
viewer: (request) => {
|
|
3046
|
+
const viewer = share.viewerOf(request);
|
|
3047
|
+
return viewer ? commentsViewer(viewer) : null;
|
|
3048
|
+
}
|
|
1318
3049
|
});
|
|
1319
|
-
const grant =
|
|
3050
|
+
const grant = randomBytes2(24).toString("base64url");
|
|
1320
3051
|
const editorPath = `/${KIND}/${grant}/`;
|
|
1321
|
-
const boot =
|
|
1322
|
-
const clients = /* @__PURE__ */ new
|
|
3052
|
+
const boot = randomBytes2(6).toString("hex");
|
|
3053
|
+
const clients = /* @__PURE__ */ new Map();
|
|
3054
|
+
const presence = new PresenceHub();
|
|
1323
3055
|
const changes = [];
|
|
1324
3056
|
let changeSeq = 0;
|
|
1325
3057
|
const pageCache = /* @__PURE__ */ new Map();
|
|
@@ -1329,18 +3061,66 @@ event: ${event.type}
|
|
|
1329
3061
|
data: ${JSON.stringify(event)}
|
|
1330
3062
|
|
|
1331
3063
|
`;
|
|
3064
|
+
const changed = (event, from) => {
|
|
3065
|
+
changeSeq += 1;
|
|
3066
|
+
changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {}, ...event.by ? { by: event.by } : {} });
|
|
3067
|
+
if (changes.length > CHANGE_LOG_SIZE) changes.shift();
|
|
3068
|
+
for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
|
|
3069
|
+
};
|
|
3070
|
+
const labels2 = () => {
|
|
3071
|
+
let named;
|
|
3072
|
+
try {
|
|
3073
|
+
named = kindLabels?.(projectDir);
|
|
3074
|
+
} catch {
|
|
3075
|
+
named = void 0;
|
|
3076
|
+
}
|
|
3077
|
+
return (p) => {
|
|
3078
|
+
const rel = p.replace(/^project\//, "");
|
|
3079
|
+
return named?.(rel) ?? plain(rel);
|
|
3080
|
+
};
|
|
3081
|
+
};
|
|
3082
|
+
const agentWorked = (paths) => {
|
|
3083
|
+
const rels = paths.map((p) => p.replace(/^project\//, ""));
|
|
3084
|
+
const main2 = [...paths].reverse().find((p) => !p.endsWith(`/${INDEX_FILE}`) && p !== INDEX_FILE) ?? paths[0];
|
|
3085
|
+
let context = null;
|
|
3086
|
+
try {
|
|
3087
|
+
context = kindAgentContext?.(projectDir, rels) ?? null;
|
|
3088
|
+
} catch {
|
|
3089
|
+
context = null;
|
|
3090
|
+
}
|
|
3091
|
+
presence.agentActive({ context, where: agentWhere(main2, paths.length, labels2()) });
|
|
3092
|
+
};
|
|
3093
|
+
const historyHost = new HistoryHost({
|
|
3094
|
+
history,
|
|
3095
|
+
labels: labels2,
|
|
3096
|
+
base: editorPath,
|
|
3097
|
+
previewUrl: (n) => `${editorPath}v/${n}/`,
|
|
3098
|
+
apply: async (files, who, extra) => {
|
|
3099
|
+
const { version, paths } = await store.applyFromHistory(files, who, extra);
|
|
3100
|
+
if (paths.length) {
|
|
3101
|
+
const listing = await store.listing();
|
|
3102
|
+
changed({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), by: authorOf(who) });
|
|
3103
|
+
}
|
|
3104
|
+
return version;
|
|
3105
|
+
},
|
|
3106
|
+
tracked: (p) => store.fileOf(p) !== null,
|
|
3107
|
+
// A notice whose overwrite named the version its saver's edits started from: what was lost goes back by line into
|
|
3108
|
+
// the file as it is now, the lost lines winning where both changed them; a deleted file gets all of it back.
|
|
3109
|
+
recover: (_path, base, lost, now) => now === null ? lost : mergeTextBytes(base, lost, now),
|
|
3110
|
+
log: stamp
|
|
3111
|
+
});
|
|
1332
3112
|
const stopWatch = watchArtifact(
|
|
1333
3113
|
store,
|
|
1334
3114
|
(event) => {
|
|
1335
|
-
changeSeq += 1;
|
|
1336
|
-
changes.push({ seq: changeSeq, paths: event.paths });
|
|
1337
|
-
if (changes.length > CHANGE_LOG_SIZE) changes.shift();
|
|
1338
3115
|
stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
|
|
1339
|
-
|
|
3116
|
+
changed({ ...event, by: authorOf(AGENT) });
|
|
3117
|
+
agentWorked(event.paths);
|
|
1340
3118
|
},
|
|
1341
3119
|
(error) => stamp(`watching failed: ${errorText(error)}`)
|
|
1342
3120
|
);
|
|
1343
|
-
const events = async (request, response) => {
|
|
3121
|
+
const events = async (request, response, viewer) => {
|
|
3122
|
+
const given = new URL(request.url ?? "/", "http://local").searchParams.get("client") ?? "";
|
|
3123
|
+
const client = CLIENT_ID.test(given) ? given : "";
|
|
1344
3124
|
response.writeHead(200, {
|
|
1345
3125
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1346
3126
|
"Cache-Control": "no-store",
|
|
@@ -1350,21 +3130,27 @@ data: ${JSON.stringify(event)}
|
|
|
1350
3130
|
response.write(": connected\n\n");
|
|
1351
3131
|
const last = /^([0-9a-f]+)\.(\d+)$/.exec(String(request.headers["last-event-id"] ?? ""));
|
|
1352
3132
|
const missedSince = last && last[1] === boot ? Number(last[2]) : null;
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
3133
|
+
const truncated = missedSince !== null && changes.length > 0 && changes[0].seq > missedSince + 1;
|
|
3134
|
+
const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
|
|
3135
|
+
if (missed.length) {
|
|
3136
|
+
const paths = [...new Set(missed.flatMap((c) => c.paths))];
|
|
3137
|
+
const listing = await store.listing();
|
|
3138
|
+
const by = missed.every((c) => c.by?.id === missed[0].by?.id) ? missed[0].by : void 0;
|
|
3139
|
+
response.write(frame({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), ...by ? { by } : {} }));
|
|
1357
3140
|
} else {
|
|
1358
3141
|
response.write(`id: ${eventId()}
|
|
1359
3142
|
|
|
1360
3143
|
`);
|
|
1361
3144
|
}
|
|
1362
|
-
clients.
|
|
3145
|
+
clients.set(response, client);
|
|
3146
|
+
share.hold(viewer, response);
|
|
3147
|
+
const leave = presence.join(client, viewer, (text) => response.write(text));
|
|
1363
3148
|
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
|
|
1364
3149
|
keepAlive.unref?.();
|
|
1365
3150
|
const done = () => {
|
|
1366
3151
|
clearInterval(keepAlive);
|
|
1367
3152
|
clients.delete(response);
|
|
3153
|
+
leave();
|
|
1368
3154
|
};
|
|
1369
3155
|
request.on("close", done);
|
|
1370
3156
|
response.on("close", done);
|
|
@@ -1389,11 +3175,11 @@ data: ${JSON.stringify(event)}
|
|
|
1389
3175
|
"Content-Security-Policy": DATA_CSP
|
|
1390
3176
|
});
|
|
1391
3177
|
};
|
|
1392
|
-
const serveEditor = async (request, response, rest) => {
|
|
3178
|
+
const serveEditor = async (request, response, rest, viewer, query = "") => {
|
|
1393
3179
|
const parts = rest === "" ? ["index.html"] : rest.split("/");
|
|
1394
3180
|
const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
|
|
1395
3181
|
const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
|
|
1396
|
-
const file = realDist === null ? null :
|
|
3182
|
+
const file = realDist === null ? null : path6.join(realDist, ...parts);
|
|
1397
3183
|
const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
|
|
1398
3184
|
const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
|
|
1399
3185
|
if (file === null || real !== file || !info?.isFile()) {
|
|
@@ -1404,25 +3190,26 @@ data: ${JSON.stringify(event)}
|
|
|
1404
3190
|
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
1405
3191
|
return;
|
|
1406
3192
|
}
|
|
1407
|
-
const ext =
|
|
3193
|
+
const ext = path6.extname(file).toLowerCase();
|
|
1408
3194
|
const type = STATIC_TYPES[ext] ?? "application/octet-stream";
|
|
1409
3195
|
if (ext === ".html") {
|
|
1410
3196
|
const key = parts.join("/");
|
|
1411
3197
|
let page = pageCache.get(key);
|
|
1412
3198
|
if (!page || page.mtimeMs !== info.mtimeMs) {
|
|
1413
3199
|
const html = await fsp2.readFile(file, "utf8");
|
|
1414
|
-
page = { mtimeMs: info.mtimeMs,
|
|
3200
|
+
page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
|
|
1415
3201
|
pageCache.set(key, page);
|
|
1416
3202
|
}
|
|
3203
|
+
const body = Buffer.from(share.injectHead(page.html, viewer, query) ?? page.html);
|
|
1417
3204
|
response.writeHead(200, {
|
|
1418
3205
|
"Content-Type": type,
|
|
1419
|
-
"Content-Length":
|
|
3206
|
+
"Content-Length": body.length,
|
|
1420
3207
|
"Cache-Control": "no-cache",
|
|
1421
3208
|
"X-Content-Type-Options": "nosniff",
|
|
1422
3209
|
"Referrer-Policy": "no-referrer",
|
|
1423
3210
|
"Content-Security-Policy": page.csp
|
|
1424
3211
|
});
|
|
1425
|
-
response.end(request.method === "HEAD" ? void 0 :
|
|
3212
|
+
response.end(request.method === "HEAD" ? void 0 : body);
|
|
1426
3213
|
return;
|
|
1427
3214
|
}
|
|
1428
3215
|
sendFile(request, response, file, {
|
|
@@ -1433,26 +3220,41 @@ data: ${JSON.stringify(event)}
|
|
|
1433
3220
|
"X-Content-Type-Options": "nosniff"
|
|
1434
3221
|
});
|
|
1435
3222
|
};
|
|
1436
|
-
const publish = async (request, response) => {
|
|
3223
|
+
const publish = async (request, response, viewer) => {
|
|
1437
3224
|
if (!/^application\/json\b/i.test(request.headers["content-type"] ?? "")) {
|
|
1438
3225
|
publishError(response, 415, "invalid_content", "expected application/json");
|
|
1439
3226
|
return;
|
|
1440
3227
|
}
|
|
1441
3228
|
const raw = await readBody(request, MAX_PUBLISH_BYTES);
|
|
1442
3229
|
let files;
|
|
3230
|
+
let over = [];
|
|
3231
|
+
let merged = [];
|
|
3232
|
+
const named = (list) => Array.isArray(list) ? list.filter((o) => typeof o?.path === "string" && typeof o.sha === "string" && SHA256.test(o.sha)).map((o) => ({ path: o.path, sha: o.sha, ...typeof o.base === "string" && SHA256.test(o.base) ? { base: o.base } : {} })) : [];
|
|
1443
3233
|
try {
|
|
1444
3234
|
const body = JSON.parse(raw.toString("utf8"));
|
|
1445
3235
|
if (typeof body?.files !== "object" || body.files === null || Array.isArray(body.files)) throw new Error("files");
|
|
1446
3236
|
if (!Object.values(body.files).every(isWireEntry)) throw new Error("entry");
|
|
1447
3237
|
files = body.files;
|
|
3238
|
+
over = named(body.over);
|
|
3239
|
+
merged = named(body.merged);
|
|
1448
3240
|
} catch {
|
|
1449
3241
|
publishError(response, 400, "invalid_content", "malformed publish request");
|
|
1450
3242
|
return;
|
|
1451
3243
|
}
|
|
1452
|
-
const outcome = await store.publish(files);
|
|
3244
|
+
const outcome = await store.publish(files, whoOf(viewer), over, merged);
|
|
1453
3245
|
const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
|
|
1454
|
-
|
|
3246
|
+
const made = outcome.version ? ` as version ${outcome.version.n}` : "";
|
|
3247
|
+
stamp(`saved from the page by ${viewer.user.name} (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""})${made}: ${names.join(", ")}`);
|
|
1455
3248
|
sendJson2(response, outcome.status, outcome.body);
|
|
3249
|
+
if (outcome.status === 200 && clients.size > 0) {
|
|
3250
|
+
const from = String(request.headers["x-deck-client"] ?? "");
|
|
3251
|
+
const shas = outcome.body.shas ?? {};
|
|
3252
|
+
const paths = Object.keys(files);
|
|
3253
|
+
changed(
|
|
3254
|
+
{ type: "external-change", paths, ver: String(outcome.body.version ?? ""), files: Object.fromEntries(paths.map((p) => [p, shas[p] ?? null])), by: authorOf(whoOf(viewer)) },
|
|
3255
|
+
CLIENT_ID.test(from) ? from : void 0
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
1456
3258
|
};
|
|
1457
3259
|
const upload = async (request, response) => {
|
|
1458
3260
|
const contentType = request.headers["content-type"] ?? "application/octet-stream";
|
|
@@ -1466,9 +3268,97 @@ data: ${JSON.stringify(event)}
|
|
|
1466
3268
|
stamp(`uploaded from the page: ${result.url} (${contentType}, ${bytes.length} bytes)`);
|
|
1467
3269
|
sendJson2(response, 200, result);
|
|
1468
3270
|
};
|
|
1469
|
-
const
|
|
3271
|
+
const session = (viewer) => {
|
|
3272
|
+
const extras = sessionExtras();
|
|
3273
|
+
if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
|
|
3274
|
+
return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
|
|
3275
|
+
};
|
|
3276
|
+
const previewRoute = async (request, response, n, rest, viewer) => {
|
|
3277
|
+
const method = request.method ?? "GET";
|
|
3278
|
+
const state = history.stateAt(n);
|
|
3279
|
+
if (!state) {
|
|
3280
|
+
request.resume();
|
|
3281
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end(`There is no version ${n} (it may be too old to keep).`);
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
if (rest === ROUTES.publish || rest === ROUTES.assets) {
|
|
3285
|
+
request.resume();
|
|
3286
|
+
publishError(response, 403, "not_writer", "This is an earlier version: open the current one to edit.");
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
|
|
3290
|
+
if (method !== "GET") {
|
|
3291
|
+
request.resume();
|
|
3292
|
+
publishError(response, 403, "not_writer", "This is an earlier version: comment on the current one.");
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
|
|
3296
|
+
}
|
|
3297
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
3298
|
+
response.writeHead(405, { Allow: "GET, HEAD" }).end();
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
if (rest === ROUTES.listing) {
|
|
3302
|
+
const files = {};
|
|
3303
|
+
for (const [p, sha] of [...state].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
3304
|
+
if (store.fileOf(p) === null) continue;
|
|
3305
|
+
files[p] = { sha256: sha, size: history.size(sha) ?? 0, contentType: contentTypeOf(p) };
|
|
3306
|
+
}
|
|
3307
|
+
return sendJson2(response, 200, { ver: `history-${n}`, files });
|
|
3308
|
+
}
|
|
3309
|
+
if (rest === ROUTES.session) return sendJson2(response, 200, { ...session(viewer), canEdit: false });
|
|
3310
|
+
if (rest === ROUTES.events) {
|
|
3311
|
+
response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
|
|
3312
|
+
response.write(": connected\n\n");
|
|
3313
|
+
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
|
|
3314
|
+
keepAlive.unref?.();
|
|
3315
|
+
request.on("close", () => clearInterval(keepAlive));
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
if (rest.startsWith("project/")) {
|
|
3319
|
+
const sha = store.fileOf(rest) === null ? void 0 : state.get(rest);
|
|
3320
|
+
const bytes = sha === void 0 ? null : history.read(sha);
|
|
3321
|
+
if (bytes === null) {
|
|
3322
|
+
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
response.writeHead(200, {
|
|
3326
|
+
"Content-Type": contentTypeOf(rest),
|
|
3327
|
+
"Content-Length": bytes.byteLength,
|
|
3328
|
+
"Cache-Control": "no-store",
|
|
3329
|
+
"X-Content-Type-Options": "nosniff",
|
|
3330
|
+
"Content-Security-Policy": DATA_CSP
|
|
3331
|
+
});
|
|
3332
|
+
response.end(method === "HEAD" ? void 0 : bytes);
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
const blob = BLOB_PATH.exec(rest);
|
|
3336
|
+
if (blob) return serveBlob(request, response, blob[1]);
|
|
3337
|
+
return serveEditor(request, response, rest, viewer, `?at=${n}`);
|
|
3338
|
+
};
|
|
3339
|
+
const route = async (request, response, rest, viewer) => {
|
|
1470
3340
|
const method = request.method ?? "GET";
|
|
1471
|
-
|
|
3341
|
+
const preview = PREVIEW_PATH.exec(rest);
|
|
3342
|
+
if (preview) {
|
|
3343
|
+
if (preview[2] === void 0) {
|
|
3344
|
+
request.resume();
|
|
3345
|
+
response.writeHead(308, { Location: `${editorPath}${rest}/`, "Cache-Control": "no-store" }).end();
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
return previewRoute(request, response, Number(preview[1]), preview[2].slice(1), viewer);
|
|
3349
|
+
}
|
|
3350
|
+
if (rest === PRESENCE_ROUTE) return presence.handle(request, response, viewer);
|
|
3351
|
+
if (rest === HISTORY_ROUTE || rest.startsWith(`${HISTORY_ROUTE}/`)) {
|
|
3352
|
+
if (await historyHost.handle(request, response, rest.slice(HISTORY_ROUTE.length), viewer)) return;
|
|
3353
|
+
request.resume();
|
|
3354
|
+
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
|
|
3358
|
+
const sub = rest.slice(COMMENTS_ROUTE.length);
|
|
3359
|
+
if (sub === "/events") share.hold(viewer, response);
|
|
3360
|
+
return comments.handle(request, response, sub);
|
|
3361
|
+
}
|
|
1472
3362
|
if (rest === ROUTES.publish || rest === ROUTES.assets) {
|
|
1473
3363
|
if (method !== "POST") {
|
|
1474
3364
|
response.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -1479,7 +3369,12 @@ data: ${JSON.stringify(event)}
|
|
|
1479
3369
|
publishError(response, 403, "not_writer", "cross-origin request");
|
|
1480
3370
|
return;
|
|
1481
3371
|
}
|
|
1482
|
-
if (
|
|
3372
|
+
if (!canEditAs(viewer.role)) {
|
|
3373
|
+
request.resume();
|
|
3374
|
+
publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
if (rest === ROUTES.publish) await publish(request, response, viewer);
|
|
1483
3378
|
else await upload(request, response);
|
|
1484
3379
|
return;
|
|
1485
3380
|
}
|
|
@@ -1488,8 +3383,8 @@ data: ${JSON.stringify(event)}
|
|
|
1488
3383
|
return;
|
|
1489
3384
|
}
|
|
1490
3385
|
if (rest === ROUTES.listing) return sendJson2(response, 200, await store.listing());
|
|
1491
|
-
if (rest === ROUTES.session) return sendJson2(response, 200,
|
|
1492
|
-
if (rest === ROUTES.events) return events(request, response);
|
|
3386
|
+
if (rest === ROUTES.session) return sendJson2(response, 200, session(viewer));
|
|
3387
|
+
if (rest === ROUTES.events) return events(request, response, viewer);
|
|
1493
3388
|
if (rest.startsWith("project/")) {
|
|
1494
3389
|
const bytes = await store.readProjectFile(rest);
|
|
1495
3390
|
if (bytes === null) {
|
|
@@ -1508,7 +3403,7 @@ data: ${JSON.stringify(event)}
|
|
|
1508
3403
|
}
|
|
1509
3404
|
const blob = BLOB_PATH.exec(rest);
|
|
1510
3405
|
if (blob) return serveBlob(request, response, blob[1]);
|
|
1511
|
-
return serveEditor(request, response, rest);
|
|
3406
|
+
return serveEditor(request, response, rest, viewer);
|
|
1512
3407
|
};
|
|
1513
3408
|
const guard = async (response, work) => {
|
|
1514
3409
|
try {
|
|
@@ -1523,17 +3418,25 @@ data: ${JSON.stringify(event)}
|
|
|
1523
3418
|
else response.destroy();
|
|
1524
3419
|
}
|
|
1525
3420
|
};
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
|
|
3421
|
+
async function handle(request, response) {
|
|
3422
|
+
if (!share.hostAllowed(request)) {
|
|
3423
|
+
request.resume();
|
|
3424
|
+
response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
|
|
1531
3425
|
return;
|
|
1532
3426
|
}
|
|
3427
|
+
const url2 = new URL(request.url ?? "/", "http://localhost");
|
|
3428
|
+
const pathname = url2.pathname;
|
|
1533
3429
|
if (pathname === "/favicon.ico") {
|
|
1534
3430
|
response.writeHead(204).end();
|
|
1535
3431
|
return;
|
|
1536
3432
|
}
|
|
3433
|
+
if (await share.route(request, response)) return;
|
|
3434
|
+
const viewer = share.viewerOf(request);
|
|
3435
|
+
if (!viewer) return share.denied(request, response);
|
|
3436
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
3437
|
+
response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
1537
3440
|
const rootBlob = BLOB_PATH.exec(pathname.slice(1));
|
|
1538
3441
|
if (rootBlob) {
|
|
1539
3442
|
void guard(response, () => serveBlob(request, response, rootBlob[1]));
|
|
@@ -1555,30 +3458,42 @@ data: ${JSON.stringify(event)}
|
|
|
1555
3458
|
response.writeHead(400).end();
|
|
1556
3459
|
return;
|
|
1557
3460
|
}
|
|
1558
|
-
void guard(response, () => route(request, response, rest));
|
|
1559
|
-
}
|
|
3461
|
+
void guard(response, () => route(request, response, rest, viewer));
|
|
3462
|
+
}
|
|
3463
|
+
const server2 = http.createServer((request, response) => void guard(response, () => handle(request, response)));
|
|
1560
3464
|
const close = () => {
|
|
1561
3465
|
stopWatch();
|
|
1562
3466
|
comments.close();
|
|
1563
|
-
|
|
3467
|
+
share.close();
|
|
3468
|
+
presence.close();
|
|
3469
|
+
for (const client of clients.keys()) client.end();
|
|
1564
3470
|
clients.clear();
|
|
1565
3471
|
server2.close();
|
|
1566
3472
|
server2.closeAllConnections?.();
|
|
1567
3473
|
};
|
|
1568
|
-
const actualPort = await listen(server2, port, host).catch((error) => {
|
|
3474
|
+
const actualPort = await share.listen(server2, { port, host, lan, portGiven }).catch((error) => {
|
|
1569
3475
|
close();
|
|
1570
3476
|
throw error;
|
|
1571
3477
|
});
|
|
1572
3478
|
const url = `${serverOrigin(host, actualPort)}${editorPath}`;
|
|
1573
3479
|
stamp(`serving ${project} at ${url}`);
|
|
1574
|
-
|
|
3480
|
+
const lanUrls = share.lanUrls();
|
|
3481
|
+
return { url, port: actualPort, grant, close, ...lanUrls.length ? { lan: lanUrls } : {} };
|
|
3482
|
+
}
|
|
3483
|
+
function artifactTitle(root, projectDir) {
|
|
3484
|
+
try {
|
|
3485
|
+
const index = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE), "utf8"));
|
|
3486
|
+
for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
|
|
3487
|
+
} catch {
|
|
3488
|
+
}
|
|
3489
|
+
return path6.basename(root);
|
|
1575
3490
|
}
|
|
1576
3491
|
var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
|
|
1577
|
-
async function serveForeground({ root, port, host, agentHook }) {
|
|
1578
|
-
const running = await startServer({ root, port, host, agentHook });
|
|
3492
|
+
async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
|
|
3493
|
+
const running = await startServer({ root, port, host, agentHook, lan, portGiven });
|
|
1579
3494
|
const state = { pid: process.pid, url: running.url, port: running.port, root, kind: KIND, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1580
3495
|
recordServer(state, running.close);
|
|
1581
|
-
return state;
|
|
3496
|
+
return { ...state, ...running.lan ? { lan: running.lan } : {} };
|
|
1582
3497
|
}
|
|
1583
3498
|
async function main(argv) {
|
|
1584
3499
|
const positional = [];
|
|
@@ -1587,26 +3502,27 @@ async function main(argv) {
|
|
|
1587
3502
|
const arg = argv[i];
|
|
1588
3503
|
if (!arg.startsWith("--")) positional.push(arg);
|
|
1589
3504
|
else if (arg.includes("=")) flags.set(arg.slice(2, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1));
|
|
3505
|
+
else if (arg === "--lan") flags.set("lan", "true");
|
|
1590
3506
|
else flags.set(arg.slice(2), argv[++i] ?? "");
|
|
1591
3507
|
}
|
|
1592
3508
|
if (positional.length !== 1 || flags.has("help")) {
|
|
1593
3509
|
process.stderr.write(
|
|
1594
|
-
`Usage: node ${
|
|
3510
|
+
`Usage: node ${path6.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
|
|
1595
3511
|
<root> holds project/${INDEX_FILE}.
|
|
1596
3512
|
`
|
|
1597
3513
|
);
|
|
1598
3514
|
return 2;
|
|
1599
3515
|
}
|
|
1600
|
-
let root =
|
|
1601
|
-
if (
|
|
1602
|
-
root =
|
|
3516
|
+
let root = path6.resolve(positional[0]);
|
|
3517
|
+
if (path6.basename(root) === "project" && fs7.existsSync(path6.join(root, INDEX_FILE)) && !fs7.existsSync(path6.join(root, "project"))) {
|
|
3518
|
+
root = path6.dirname(root);
|
|
1603
3519
|
}
|
|
1604
|
-
if (!
|
|
1605
|
-
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${
|
|
3520
|
+
if (!fs7.statSync(path6.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
|
|
3521
|
+
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path6.join(root, "project", INDEX_FILE)} does not exist.` })}
|
|
1606
3522
|
`);
|
|
1607
3523
|
return 2;
|
|
1608
3524
|
}
|
|
1609
|
-
const port = Number(flags.get("port")
|
|
3525
|
+
const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS[KIND];
|
|
1610
3526
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1611
3527
|
process.stderr.write(`${JSON.stringify({ error: "INVALID_PORT", message: "--port is a number from 0 to 65535." })}
|
|
1612
3528
|
`);
|
|
@@ -1619,14 +3535,14 @@ async function main(argv) {
|
|
|
1619
3535
|
return 0;
|
|
1620
3536
|
}
|
|
1621
3537
|
const agentHook = flags.get("agent-hook") || void 0;
|
|
1622
|
-
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook });
|
|
1623
|
-
process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid }, null, 2)}
|
|
3538
|
+
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port") });
|
|
3539
|
+
process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid, ...state.lan ? { lan: state.lan } : {} }, null, 2)}
|
|
1624
3540
|
`);
|
|
1625
3541
|
return -1;
|
|
1626
3542
|
}
|
|
1627
3543
|
function isMain(moduleUrl) {
|
|
1628
3544
|
try {
|
|
1629
|
-
return Boolean(process.argv[1]) && pathToFileURL(
|
|
3545
|
+
return Boolean(process.argv[1]) && pathToFileURL(fs7.realpathSync(process.argv[1])).href === moduleUrl;
|
|
1630
3546
|
} catch {
|
|
1631
3547
|
return false;
|
|
1632
3548
|
}
|
|
@@ -1639,8 +3555,10 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
1639
3555
|
pageScriptSrc = kind.pageScriptSrc;
|
|
1640
3556
|
sessionExtras = kind.sessionExtras;
|
|
1641
3557
|
commentLocation = kind.commentLocation;
|
|
3558
|
+
kindLabels = kind.labels;
|
|
3559
|
+
kindAgentContext = kind.agentContext;
|
|
1642
3560
|
SCRIPT = fileURLToPath(moduleUrl);
|
|
1643
|
-
EDITOR_DIR =
|
|
3561
|
+
EDITOR_DIR = path6.join(path6.dirname(SCRIPT), "editor");
|
|
1644
3562
|
const runIfMain = () => {
|
|
1645
3563
|
if (!isMain(moduleUrl)) return;
|
|
1646
3564
|
main(process.argv.slice(2)).then(
|
|
@@ -1648,7 +3566,8 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
1648
3566
|
if (code >= 0) process.exitCode = code;
|
|
1649
3567
|
},
|
|
1650
3568
|
(error) => {
|
|
1651
|
-
|
|
3569
|
+
const code = error.code === "PORT_IN_USE" ? "PORT_IN_USE" : "SERVER_FAILED";
|
|
3570
|
+
process.stderr.write(`${JSON.stringify({ error: code, message: errorText(error) })}
|
|
1652
3571
|
`);
|
|
1653
3572
|
process.exitCode = 2;
|
|
1654
3573
|
}
|
|
@@ -1690,15 +3609,45 @@ function commentLocation2(input, projectDir) {
|
|
|
1690
3609
|
let slideId = match[2];
|
|
1691
3610
|
if (!slideId) {
|
|
1692
3611
|
try {
|
|
1693
|
-
const order = JSON.parse(
|
|
3612
|
+
const order = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
1694
3613
|
if (Array.isArray(order) && typeof order[slide - 1] === "string") slideId = order[slide - 1];
|
|
1695
3614
|
} catch {
|
|
1696
3615
|
}
|
|
1697
3616
|
}
|
|
1698
3617
|
const file = slideId ? projectFile(`slides/${slideId}.html`) : void 0;
|
|
1699
|
-
if (slideId && file &&
|
|
3618
|
+
if (slideId && file && fs8.existsSync(path7.join(projectDir, "slides", `${slideId}.html`))) return { file, slide, slideId };
|
|
1700
3619
|
return { file: "project/deck.json", slide };
|
|
1701
3620
|
}
|
|
3621
|
+
function labels(projectDir) {
|
|
3622
|
+
let order = [];
|
|
3623
|
+
try {
|
|
3624
|
+
const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
3625
|
+
if (Array.isArray(listed)) order = listed.filter((id) => typeof id === "string");
|
|
3626
|
+
} catch {
|
|
3627
|
+
}
|
|
3628
|
+
return (rel) => {
|
|
3629
|
+
if (rel === INDEX_FILE2) return { en: "Slide order and settings", zh: "\u9875\u9762\u987A\u5E8F\u548C\u8BBE\u7F6E" };
|
|
3630
|
+
const slide = /^slides\/(.+)\.html$/.exec(rel);
|
|
3631
|
+
if (slide) {
|
|
3632
|
+
const at = order.indexOf(slide[1]);
|
|
3633
|
+
return at < 0 ? { en: `Slide ${slide[1]}`, zh: `\u5E7B\u706F\u7247 ${slide[1]}` } : { en: `Slide ${at + 1} \xB7 ${slide[1]}`, zh: `\u7B2C ${at + 1} \u9875 \xB7 ${slide[1]}` };
|
|
3634
|
+
}
|
|
3635
|
+
if (rel.startsWith("ds/")) return { en: `Design system \xB7 ${rel.slice(3)}`, zh: `\u8BBE\u8BA1\u7CFB\u7EDF \xB7 ${rel.slice(3)}` };
|
|
3636
|
+
return null;
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
function agentContext(projectDir, rels) {
|
|
3640
|
+
const slideId = [...rels].reverse().map((rel) => /^slides\/(.+)\.html$/.exec(rel)?.[1]).find((id) => id !== void 0);
|
|
3641
|
+
if (!slideId) return null;
|
|
3642
|
+
let order = [];
|
|
3643
|
+
try {
|
|
3644
|
+
const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).order;
|
|
3645
|
+
if (Array.isArray(listed)) order = listed.filter((id) => typeof id === "string");
|
|
3646
|
+
} catch {
|
|
3647
|
+
}
|
|
3648
|
+
const at = order.indexOf(slideId);
|
|
3649
|
+
return { mode: "edit", deck: null, slideId, ...at < 0 ? {} : { slideIndex: at + 1, slideCount: order.length } };
|
|
3650
|
+
}
|
|
1702
3651
|
var server = createArtifactServer(
|
|
1703
3652
|
{
|
|
1704
3653
|
kind: KIND2,
|
|
@@ -1712,7 +3661,9 @@ var server = createArtifactServer(
|
|
|
1712
3661
|
*/
|
|
1713
3662
|
pageScriptSrc: (html) => `script-src 'self' 'unsafe-eval' ${inlineScriptHashes(html).map((h) => `'${h}'`).join(" ")}`.trim(),
|
|
1714
3663
|
sessionExtras: () => ({}),
|
|
1715
|
-
commentLocation: commentLocation2
|
|
3664
|
+
commentLocation: commentLocation2,
|
|
3665
|
+
labels,
|
|
3666
|
+
agentContext
|
|
1716
3667
|
},
|
|
1717
3668
|
import.meta.url
|
|
1718
3669
|
);
|