dsh-claude-design 2.4.0 → 2.5.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/LICENSE +5 -0
- package/NOTICE.md +58 -0
- package/README.md +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +1 -1
- package/package.json +4 -3
- package/skills/claude-design/SKILL.md +14 -1
- package/skills/claude-design/scripts/artifact.mjs +316 -78
- package/skills/claude-design/scripts/editor/assets/{index-DqKyhcn7.js → index-Cs8iq2HP.js} +204 -204
- package/skills/claude-design/scripts/editor/index.html +2 -2
- package/skills/claude-design/scripts/editor/share-ui.js +83 -0
- package/skills/claude-design/scripts/server.mjs +818 -96
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// claude-design: local editor server (prebuilt; do not edit).
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs7 from "node:fs";
|
|
5
|
+
import path6 from "node:path";
|
|
6
6
|
|
|
7
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
-
import
|
|
7
|
+
import { createHash as createHash2, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import fs6 from "node:fs";
|
|
9
9
|
import fsp2 from "node:fs/promises";
|
|
10
10
|
import http from "node:http";
|
|
11
|
-
import
|
|
11
|
+
import path5 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;
|
|
@@ -332,9 +338,10 @@ function defaultUserName() {
|
|
|
332
338
|
function createCommentsService(options) {
|
|
333
339
|
const { root, log, agentHook } = options;
|
|
334
340
|
const userName = (options.userName ?? defaultUserName()).slice(0, 100);
|
|
335
|
-
const
|
|
341
|
+
const owner = { author: { kind: "user", name: userName }, canComment: true, canSend: true, admin: true };
|
|
342
|
+
const viewerOf = options.viewer ?? (() => owner);
|
|
336
343
|
const allowWrite = options.allowWrite ?? (() => true);
|
|
337
|
-
const clients = /* @__PURE__ */ new
|
|
344
|
+
const clients = /* @__PURE__ */ new Map();
|
|
338
345
|
const waiters = [];
|
|
339
346
|
const hookRunning = /* @__PURE__ */ new Set();
|
|
340
347
|
const requests = [];
|
|
@@ -354,9 +361,19 @@ function createCommentsService(options) {
|
|
|
354
361
|
const hook = Boolean(agentHook);
|
|
355
362
|
return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
|
|
356
363
|
}
|
|
357
|
-
|
|
364
|
+
const meOf = (viewer) => ({
|
|
365
|
+
name: viewer.author.name,
|
|
366
|
+
...viewer.author.principal ? { principal: viewer.author.principal } : {},
|
|
367
|
+
canComment: viewer.canComment,
|
|
368
|
+
canSend: viewer.canSend,
|
|
369
|
+
admin: viewer.admin
|
|
370
|
+
});
|
|
371
|
+
async function shared() {
|
|
358
372
|
lastDoc = await readComments(root);
|
|
359
|
-
return { threads: lastDoc.threads, agent: presence()
|
|
373
|
+
return { threads: lastDoc.threads, agent: presence() };
|
|
374
|
+
}
|
|
375
|
+
async function snapshot(viewer) {
|
|
376
|
+
return { ...await shared(), me: meOf(viewer) };
|
|
360
377
|
}
|
|
361
378
|
const frame = (snap) => `event: comments
|
|
362
379
|
data: ${JSON.stringify(snap)}
|
|
@@ -365,18 +382,18 @@ data: ${JSON.stringify(snap)}
|
|
|
365
382
|
function broadcast() {
|
|
366
383
|
queue = queue.then(async () => {
|
|
367
384
|
if (closed) return;
|
|
368
|
-
let
|
|
385
|
+
let base;
|
|
369
386
|
try {
|
|
370
|
-
|
|
387
|
+
base = await shared();
|
|
371
388
|
} catch (error) {
|
|
372
389
|
log(`comments: reading ${COMMENTS_FILE} failed: ${error.message}`);
|
|
373
390
|
return;
|
|
374
391
|
}
|
|
375
|
-
const text =
|
|
376
|
-
lastPresence = JSON.stringify(
|
|
392
|
+
const text = JSON.stringify(base);
|
|
393
|
+
lastPresence = JSON.stringify(base.agent);
|
|
377
394
|
if (text === lastSent) return;
|
|
378
395
|
lastSent = text;
|
|
379
|
-
for (const client of clients) client.write(
|
|
396
|
+
for (const [client, viewer] of clients) client.write(frame({ ...base, me: meOf(viewer) }));
|
|
380
397
|
});
|
|
381
398
|
return queue;
|
|
382
399
|
}
|
|
@@ -532,16 +549,21 @@ data: ${JSON.stringify(snap)}
|
|
|
532
549
|
await broadcast();
|
|
533
550
|
if (sent) await deliver();
|
|
534
551
|
}
|
|
535
|
-
|
|
552
|
+
function need(allowed, message) {
|
|
553
|
+
if (!allowed) throw new CommentsError("NOT_ALLOWED", message, 403);
|
|
554
|
+
}
|
|
555
|
+
const ONLY_OWNER_SENDS = "Only the owner can send comments to the agent.";
|
|
556
|
+
async function create(request, response, viewer) {
|
|
536
557
|
const input = readNewThread(await readJson(request));
|
|
537
558
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {anchor, label, text} with a non-empty text");
|
|
559
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
538
560
|
let location = {};
|
|
539
561
|
try {
|
|
540
562
|
location = await options.locate?.(input) ?? {};
|
|
541
563
|
} catch (error) {
|
|
542
564
|
log(`comments: could not resolve where a comment points: ${error.message}`);
|
|
543
565
|
}
|
|
544
|
-
const created = await updateComments(root, (doc) => addThread(doc, input, location,
|
|
566
|
+
const created = await updateComments(root, (doc) => addThread(doc, input, location, viewer.author, now()));
|
|
545
567
|
log(`comments: ${created.id} added on ${created.label}${location.file ? ` (${location.file})` : ""}${input.send ? ", sent to the agent" : ""}`);
|
|
546
568
|
sendJson(response, 201, { thread: created });
|
|
547
569
|
await afterChange(Boolean(input.send));
|
|
@@ -556,7 +578,7 @@ data: ${JSON.stringify(snap)}
|
|
|
556
578
|
sendJson(response, 202, { request: queued });
|
|
557
579
|
await deliver();
|
|
558
580
|
}
|
|
559
|
-
async function events(request, response) {
|
|
581
|
+
async function events(request, response, viewer) {
|
|
560
582
|
response.writeHead(200, {
|
|
561
583
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
562
584
|
"Cache-Control": "no-store",
|
|
@@ -564,8 +586,8 @@ data: ${JSON.stringify(snap)}
|
|
|
564
586
|
"X-Accel-Buffering": "no"
|
|
565
587
|
});
|
|
566
588
|
response.write(": connected\n\n");
|
|
567
|
-
response.write(frame(await snapshot()));
|
|
568
|
-
clients.
|
|
589
|
+
response.write(frame(await snapshot(viewer)));
|
|
590
|
+
clients.set(response, viewer);
|
|
569
591
|
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS);
|
|
570
592
|
keepAlive.unref?.();
|
|
571
593
|
response.on("close", () => {
|
|
@@ -601,14 +623,26 @@ data: ${JSON.stringify(snap)}
|
|
|
601
623
|
request.resume();
|
|
602
624
|
throw new CommentsError("NOT_ALLOWED", "cross-origin request", 403);
|
|
603
625
|
}
|
|
626
|
+
const viewer = viewerOf(request);
|
|
627
|
+
if (!viewer) {
|
|
628
|
+
request.resume();
|
|
629
|
+
throw new CommentsError("NOT_ALLOWED", "not shared with you", 403);
|
|
630
|
+
}
|
|
631
|
+
const commenting = !reading && parts[0] !== "agent" && parts[1] !== "send";
|
|
632
|
+
if (commenting && !viewer.canComment) {
|
|
633
|
+
request.resume();
|
|
634
|
+
throw new CommentsError("NOT_ALLOWED", "You can read the comments here, not add or change them.", 403);
|
|
635
|
+
}
|
|
604
636
|
if (parts.length === 0) {
|
|
605
|
-
if (reading) return sendJson(response, 200, await snapshot());
|
|
606
|
-
if (method === "POST") return await create(request, response);
|
|
637
|
+
if (reading) return sendJson(response, 200, await snapshot(viewer));
|
|
638
|
+
if (method === "POST") return await create(request, response, viewer);
|
|
607
639
|
} else if (parts.length === 1 && parts[0] === "events") {
|
|
608
|
-
if (reading) return await events(request, response);
|
|
640
|
+
if (reading) return await events(request, response, viewer);
|
|
609
641
|
} else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "wait") {
|
|
642
|
+
need(viewer.canSend, "Only the owner's agent waits for comments.");
|
|
610
643
|
if (reading) return await wait(request, response, url);
|
|
611
644
|
} else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "requests") {
|
|
645
|
+
need(viewer.canSend, ONLY_OWNER_SENDS);
|
|
612
646
|
if (method === "POST") return await ask(request, response);
|
|
613
647
|
} else if (parts.length === 1) {
|
|
614
648
|
const id = parts[0];
|
|
@@ -627,6 +661,8 @@ data: ${JSON.stringify(snap)}
|
|
|
627
661
|
if (method === "DELETE") {
|
|
628
662
|
await updateComments(root, (doc) => {
|
|
629
663
|
const found = findThread(doc, id);
|
|
664
|
+
const first = found.messages[0];
|
|
665
|
+
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
666
|
doc.threads.splice(doc.threads.indexOf(found), 1);
|
|
631
667
|
});
|
|
632
668
|
log(`comments: ${id} deleted`);
|
|
@@ -638,9 +674,10 @@ data: ${JSON.stringify(snap)}
|
|
|
638
674
|
const id = parts[0];
|
|
639
675
|
const input = readReply(await readJson(request));
|
|
640
676
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
|
|
677
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
641
678
|
const t = await updateComments(root, (doc) => {
|
|
642
679
|
const found = findThread(doc, id);
|
|
643
|
-
addMessage(found,
|
|
680
|
+
addMessage(found, viewer.author, input.text, now());
|
|
644
681
|
if (input.send) sendToAgent(found, now());
|
|
645
682
|
return found;
|
|
646
683
|
});
|
|
@@ -654,9 +691,10 @@ data: ${JSON.stringify(snap)}
|
|
|
654
691
|
const messageId = parts[2];
|
|
655
692
|
const input = readEdit(await readJson(request));
|
|
656
693
|
if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
|
|
694
|
+
need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
|
|
657
695
|
const t = await updateComments(root, (doc) => {
|
|
658
696
|
const found = findThread(doc, id);
|
|
659
|
-
editMessage(found, messageId, input.text, now());
|
|
697
|
+
editMessage(found, messageId, input.text, now(), viewer.author);
|
|
660
698
|
if (input.send && canSendToAgent(found)) sendToAgent(found, now());
|
|
661
699
|
return found;
|
|
662
700
|
});
|
|
@@ -668,6 +706,7 @@ data: ${JSON.stringify(snap)}
|
|
|
668
706
|
if (method === "POST") {
|
|
669
707
|
const id = parts[0];
|
|
670
708
|
request.resume();
|
|
709
|
+
need(viewer.canSend, ONLY_OWNER_SENDS);
|
|
671
710
|
const t = await updateComments(root, (doc) => {
|
|
672
711
|
const found = findThread(doc, id);
|
|
673
712
|
if (!canSendToAgent(found)) throw new CommentsError("NOTHING_TO_SEND", "the agent already has everything in this comment", 409);
|
|
@@ -696,13 +735,15 @@ data: ${JSON.stringify(snap)}
|
|
|
696
735
|
clearTimeout(debounce);
|
|
697
736
|
watcher?.close();
|
|
698
737
|
for (const waiter of [...waiters]) finishWaiter(waiter, { threads: [], closed: true });
|
|
699
|
-
for (const client of clients) client.end();
|
|
738
|
+
for (const client of clients.keys()) client.end();
|
|
700
739
|
clients.clear();
|
|
701
740
|
}
|
|
702
741
|
return { handle, presence, deliver, close };
|
|
703
742
|
}
|
|
704
743
|
|
|
705
744
|
import fs3 from "node:fs";
|
|
745
|
+
import net from "node:net";
|
|
746
|
+
import os2 from "node:os";
|
|
706
747
|
import path2 from "node:path";
|
|
707
748
|
var STATE_FILE = ".artifact-server.json";
|
|
708
749
|
function readState(root) {
|
|
@@ -749,6 +790,54 @@ async function listen(server2, port, host) {
|
|
|
749
790
|
const address = server2.address();
|
|
750
791
|
return typeof address === "object" && address ? address.port : port;
|
|
751
792
|
}
|
|
793
|
+
var DEFAULT_PORTS = { slides: 4710, design: 4720, "design-system": 4730, dashboard: 4740, docs: 4750, motion: 4760 };
|
|
794
|
+
var PORT_TRIES = 100;
|
|
795
|
+
var PortsTaken = class extends Error {
|
|
796
|
+
code = "PORT_IN_USE";
|
|
797
|
+
from;
|
|
798
|
+
to;
|
|
799
|
+
constructor(from, to) {
|
|
800
|
+
super(`${from === to ? `Port ${from} is` : `Ports ${from} to ${to} are all`} in use; pick another --port (0 takes any free one).`);
|
|
801
|
+
this.from = from;
|
|
802
|
+
this.to = to;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
function lanAddresses() {
|
|
806
|
+
const out = [];
|
|
807
|
+
for (const list of Object.values(os2.networkInterfaces())) {
|
|
808
|
+
for (const entry of list ?? []) if (entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254.")) out.push(entry.address);
|
|
809
|
+
}
|
|
810
|
+
return [...new Set(out)];
|
|
811
|
+
}
|
|
812
|
+
function answers(host, port) {
|
|
813
|
+
return new Promise((resolve) => {
|
|
814
|
+
const socket = net.connect({ host, port });
|
|
815
|
+
const done = (taken) => {
|
|
816
|
+
socket.destroy();
|
|
817
|
+
resolve(taken);
|
|
818
|
+
};
|
|
819
|
+
socket.setTimeout(300, () => done(true));
|
|
820
|
+
socket.once("connect", () => done(true));
|
|
821
|
+
socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async function listenFree(server2, from, host, first) {
|
|
825
|
+
if (from === 0) return listen(server2, 0, host);
|
|
826
|
+
const to = Math.min(from + PORT_TRIES - 1, 65535);
|
|
827
|
+
const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
828
|
+
const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
|
|
829
|
+
const wildcard = host === "0.0.0.0" || host === "::";
|
|
830
|
+
const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lanAddresses()])];
|
|
831
|
+
for (const port of ports) {
|
|
832
|
+
if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
|
|
833
|
+
try {
|
|
834
|
+
return await listen(server2, port, host);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
throw new PortsTaken(from, to);
|
|
840
|
+
}
|
|
752
841
|
function serverOrigin(host, port) {
|
|
753
842
|
const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
|
|
754
843
|
return `http://${shown}:${port}`;
|
|
@@ -813,6 +902,575 @@ function sendFile(request, response, file, headers) {
|
|
|
813
902
|
}
|
|
814
903
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
815
904
|
|
|
905
|
+
import { createHash } from "node:crypto";
|
|
906
|
+
import fs5 from "node:fs";
|
|
907
|
+
import os3 from "node:os";
|
|
908
|
+
import path4 from "node:path";
|
|
909
|
+
|
|
910
|
+
import crypto from "node:crypto";
|
|
911
|
+
import fs4 from "node:fs";
|
|
912
|
+
import path3 from "node:path";
|
|
913
|
+
var GUEST_ROLES = ["editor", "commenter", "viewer"];
|
|
914
|
+
var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
|
|
915
|
+
var SHARING_FILE = ".sharing.json";
|
|
916
|
+
var MAX_GUESTS = 200;
|
|
917
|
+
var MAX_NAME = 40;
|
|
918
|
+
var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
|
|
919
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
920
|
+
function fresh() {
|
|
921
|
+
return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
|
|
922
|
+
}
|
|
923
|
+
function cleanName(raw) {
|
|
924
|
+
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
|
|
925
|
+
return [...name].slice(0, MAX_NAME).join("");
|
|
926
|
+
}
|
|
927
|
+
function guestUser(guest) {
|
|
928
|
+
return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
|
|
929
|
+
}
|
|
930
|
+
function isLoopback(address) {
|
|
931
|
+
if (!address) return false;
|
|
932
|
+
const a = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
933
|
+
return a === "::1" || a.startsWith("127.");
|
|
934
|
+
}
|
|
935
|
+
var Sharing = class _Sharing {
|
|
936
|
+
file;
|
|
937
|
+
state;
|
|
938
|
+
constructor(root) {
|
|
939
|
+
this.file = path3.join(root, SHARING_FILE);
|
|
940
|
+
this.state = _Sharing.read(this.file);
|
|
941
|
+
}
|
|
942
|
+
static read(file) {
|
|
943
|
+
let raw;
|
|
944
|
+
try {
|
|
945
|
+
raw = JSON.parse(fs4.readFileSync(file, "utf8"));
|
|
946
|
+
} catch {
|
|
947
|
+
return fresh();
|
|
948
|
+
}
|
|
949
|
+
const state = fresh();
|
|
950
|
+
if (!isRecord2(raw)) return state;
|
|
951
|
+
state.lan = raw.lan === true;
|
|
952
|
+
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
|
|
953
|
+
if (isRecord2(raw.links)) {
|
|
954
|
+
for (const role of GUEST_ROLES) {
|
|
955
|
+
const link = raw.links[role];
|
|
956
|
+
if (isRecord2(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (Array.isArray(raw.guests)) {
|
|
960
|
+
for (const g of raw.guests) {
|
|
961
|
+
if (!isRecord2(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
962
|
+
state.guests.push({
|
|
963
|
+
id: g.id,
|
|
964
|
+
secret: g.secret,
|
|
965
|
+
name: cleanName(g.name) || "Guest",
|
|
966
|
+
role: g.role,
|
|
967
|
+
link: g.link,
|
|
968
|
+
joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
969
|
+
...typeof g.seen === "string" ? { seen: g.seen } : {}
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return state;
|
|
974
|
+
}
|
|
975
|
+
save() {
|
|
976
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
977
|
+
fs4.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
|
|
978
|
+
`, { mode: 384 });
|
|
979
|
+
fs4.renameSync(tmp, this.file);
|
|
980
|
+
}
|
|
981
|
+
/** The role whose link has this token, while that link is on. */
|
|
982
|
+
linkRole(tokenGiven) {
|
|
983
|
+
for (const role of GUEST_ROLES) {
|
|
984
|
+
const link = this.state.links[role];
|
|
985
|
+
if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
|
|
986
|
+
}
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
990
|
+
guest(secret) {
|
|
991
|
+
if (!secret) return null;
|
|
992
|
+
const guest = this.state.guests.find((g) => g.secret === secret);
|
|
993
|
+
return guest && this.valid(guest) ? guest : null;
|
|
994
|
+
}
|
|
995
|
+
valid(guest) {
|
|
996
|
+
const link = this.state.links[guest.role];
|
|
997
|
+
return link.on && link.token === guest.link && this.state.guests.includes(guest);
|
|
998
|
+
}
|
|
999
|
+
join(role, name) {
|
|
1000
|
+
const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1001
|
+
this.state.guests.push(guest);
|
|
1002
|
+
if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
|
|
1003
|
+
this.save();
|
|
1004
|
+
return guest;
|
|
1005
|
+
}
|
|
1006
|
+
/** Marks a guest seen now (saved at most once a minute per guest). */
|
|
1007
|
+
seen(guest) {
|
|
1008
|
+
const now = /* @__PURE__ */ new Date();
|
|
1009
|
+
if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
|
|
1010
|
+
guest.seen = now.toISOString();
|
|
1011
|
+
this.save();
|
|
1012
|
+
}
|
|
1013
|
+
setLan(on, port) {
|
|
1014
|
+
this.state.lan = on;
|
|
1015
|
+
if (on && port) this.state.port = port;
|
|
1016
|
+
this.save();
|
|
1017
|
+
}
|
|
1018
|
+
setLink(role, on) {
|
|
1019
|
+
this.state.links[role].on = on;
|
|
1020
|
+
this.save();
|
|
1021
|
+
}
|
|
1022
|
+
/** A new token for the link: the old link stops working and the guests who came through it are dropped. */
|
|
1023
|
+
resetLink(role) {
|
|
1024
|
+
const old = this.state.links[role].token;
|
|
1025
|
+
this.state.links[role] = { token: token(), on: this.state.links[role].on };
|
|
1026
|
+
this.state.guests = this.state.guests.filter((g) => g.link !== old);
|
|
1027
|
+
this.save();
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
|
|
1031
|
+
* whose link is off would shut them out: false then, as for a guest who isn't there.
|
|
1032
|
+
*/
|
|
1033
|
+
setRole(id, role) {
|
|
1034
|
+
const guest = this.state.guests.find((g) => g.id === id);
|
|
1035
|
+
if (!guest || !this.state.links[role].on) return false;
|
|
1036
|
+
guest.role = role;
|
|
1037
|
+
guest.link = this.state.links[role].token;
|
|
1038
|
+
this.save();
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
removeGuest(id) {
|
|
1042
|
+
const before = this.state.guests.length;
|
|
1043
|
+
this.state.guests = this.state.guests.filter((g) => g.id !== id);
|
|
1044
|
+
if (this.state.guests.length === before) return false;
|
|
1045
|
+
this.save();
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
var NOUNS = {
|
|
1051
|
+
docs: { en: "doc", zh: "\u6587\u6863", zhThis: "\u8FD9\u7BC7\u6587\u6863" },
|
|
1052
|
+
slides: { en: "deck", zh: "\u6F14\u793A\u6587\u7A3F", zhThis: "\u8FD9\u4EFD\u6F14\u793A\u6587\u7A3F" },
|
|
1053
|
+
design: { en: "design", zh: "\u8BBE\u8BA1\u7A3F", zhThis: "\u8FD9\u4EFD\u8BBE\u8BA1\u7A3F" },
|
|
1054
|
+
"design-system": { en: "design system", zh: "\u8BBE\u8BA1\u7CFB\u7EDF", zhThis: "\u8FD9\u4E2A\u8BBE\u8BA1\u7CFB\u7EDF" },
|
|
1055
|
+
dashboard: { en: "dashboard", zh: "\u4EEA\u8868\u76D8", zhThis: "\u8FD9\u4E2A\u4EEA\u8868\u76D8" },
|
|
1056
|
+
motion: { en: "animation", zh: "\u52A8\u753B", zhThis: "\u8FD9\u4E2A\u52A8\u753B" }
|
|
1057
|
+
};
|
|
1058
|
+
var ShareError = class extends Error {
|
|
1059
|
+
};
|
|
1060
|
+
var canEditAs = (role) => role === "owner" || role === "editor";
|
|
1061
|
+
function commentsViewer(viewer) {
|
|
1062
|
+
return {
|
|
1063
|
+
author: viewer.guest ? { kind: "user", name: viewer.user.name, principal: viewer.user.email } : { kind: "user", name: viewer.user.name },
|
|
1064
|
+
canComment: viewer.role !== "viewer",
|
|
1065
|
+
canSend: viewer.role === "owner",
|
|
1066
|
+
admin: viewer.role === "owner"
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1070
|
+
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1071
|
+
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1072
|
+
function ownHosts(host, port) {
|
|
1073
|
+
const named = host === "0.0.0.0" || host === "::" ? [] : [host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`];
|
|
1074
|
+
return [.../* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...named])];
|
|
1075
|
+
}
|
|
1076
|
+
function localOwner() {
|
|
1077
|
+
let name = "You";
|
|
1078
|
+
try {
|
|
1079
|
+
name = os3.userInfo().username.trim() || name;
|
|
1080
|
+
} catch {
|
|
1081
|
+
}
|
|
1082
|
+
return { id: "local", name, email: `${name}@localhost` };
|
|
1083
|
+
}
|
|
1084
|
+
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash("sha256").update(path4.resolve(root)).digest("hex").slice(0, 12)}`;
|
|
1085
|
+
var ShareHost = class {
|
|
1086
|
+
sharing;
|
|
1087
|
+
options;
|
|
1088
|
+
/** The port the server listens on (set by `listening`); the LAN listeners take the same one. */
|
|
1089
|
+
port = 0;
|
|
1090
|
+
hosts = /* @__PURE__ */ new Set();
|
|
1091
|
+
/** One listener per LAN address while the artifact is open to the network. */
|
|
1092
|
+
lanServers = /* @__PURE__ */ new Map();
|
|
1093
|
+
live = /* @__PURE__ */ new Set();
|
|
1094
|
+
constructor(options) {
|
|
1095
|
+
this.options = options;
|
|
1096
|
+
this.sharing = new Sharing(options.root);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Starts `server` on the first free port from `port` (a shared artifact tries the port its links were given out with
|
|
1100
|
+
* first, so they keep working, unless the port was given), shares it on the network with `lan` (or as it was left)
|
|
1101
|
+
* and answers the port taken. If this throws, the caller closes what it started.
|
|
1102
|
+
*/
|
|
1103
|
+
async listen(server2, { port, host, lan = false, portGiven = false }) {
|
|
1104
|
+
const { state } = this.sharing;
|
|
1105
|
+
const linksPort = !portGiven && (lan || state.lan) && state.port ? state.port : void 0;
|
|
1106
|
+
const taken = await listenFree(server2, port, host, linksPort);
|
|
1107
|
+
if (linksPort && taken !== linksPort) this.options.log(`port ${linksPort} (the shared links') is in use; took ${taken}: give the new links out again`);
|
|
1108
|
+
else if (!linksPort && port && taken !== port) this.options.log(`port ${port} is in use; took ${taken}`);
|
|
1109
|
+
if (lan && !this.sharing.state.lan) this.sharing.setLan(true, taken);
|
|
1110
|
+
await this.listening(taken, ownHosts(host, taken));
|
|
1111
|
+
return taken;
|
|
1112
|
+
}
|
|
1113
|
+
/** The artifact at each LAN address it is shared on ([] while it is not). */
|
|
1114
|
+
lanUrls() {
|
|
1115
|
+
return [...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`);
|
|
1116
|
+
}
|
|
1117
|
+
/** The main listener is up on `port` (answering `hosts`): open to the network now if the artifact was left shared. */
|
|
1118
|
+
async listening(port, hosts) {
|
|
1119
|
+
this.port = port;
|
|
1120
|
+
this.hosts = new Set(hosts);
|
|
1121
|
+
if (this.sharing.state.lan) await this.setLan(true);
|
|
1122
|
+
}
|
|
1123
|
+
/** Open to the local network now. */
|
|
1124
|
+
get shared() {
|
|
1125
|
+
return this.lanServers.size > 0;
|
|
1126
|
+
}
|
|
1127
|
+
/** A Host header this server answers: its own names, and while shared, its LAN addresses and machine name. */
|
|
1128
|
+
hostAllowed(request) {
|
|
1129
|
+
const host = request.headers.host ?? "";
|
|
1130
|
+
if (this.hosts.has(host)) return true;
|
|
1131
|
+
if (!this.lanServers.size) return false;
|
|
1132
|
+
const suffix = `:${this.port}`;
|
|
1133
|
+
if (!host.endsWith(suffix)) return false;
|
|
1134
|
+
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
1135
|
+
const machine = os3.hostname().toLowerCase();
|
|
1136
|
+
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Opens the artifact to the local network (a listener on each LAN address, on the server's port) or closes it.
|
|
1140
|
+
* Returns the addresses it listens on. Guests already in keep their place for when it opens again.
|
|
1141
|
+
*/
|
|
1142
|
+
async setLan(on) {
|
|
1143
|
+
if (!on) {
|
|
1144
|
+
this.closeLan();
|
|
1145
|
+
for (const live of [...this.live]) if (live.viewer.guest) live.close("unshared");
|
|
1146
|
+
if (this.sharing.state.lan) this.sharing.setLan(false);
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
for (const address of lanAddresses()) {
|
|
1150
|
+
if (this.lanServers.has(address)) continue;
|
|
1151
|
+
const server2 = this.options.createServer();
|
|
1152
|
+
try {
|
|
1153
|
+
await listen(server2, this.port, address);
|
|
1154
|
+
this.lanServers.set(address, server2);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
server2.close();
|
|
1157
|
+
this.options.log(`not listening on ${address}:${this.port}: ${error.message}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (!this.sharing.state.lan || this.sharing.state.port !== this.port) this.sharing.setLan(true, this.port);
|
|
1161
|
+
this.options.log(`shared on the local network: ${[...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`).join(", ") || "(no LAN address)"}`);
|
|
1162
|
+
return [...this.lanServers.keys()];
|
|
1163
|
+
}
|
|
1164
|
+
closeLan() {
|
|
1165
|
+
for (const server2 of this.lanServers.values()) {
|
|
1166
|
+
server2.close();
|
|
1167
|
+
server2.closeAllConnections?.();
|
|
1168
|
+
}
|
|
1169
|
+
this.lanServers.clear();
|
|
1170
|
+
}
|
|
1171
|
+
/** Stops the LAN listeners (the server closes its own). */
|
|
1172
|
+
close() {
|
|
1173
|
+
this.closeLan();
|
|
1174
|
+
}
|
|
1175
|
+
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
1176
|
+
cookie(request) {
|
|
1177
|
+
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
1178
|
+
const eq = part.indexOf("=");
|
|
1179
|
+
if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
|
|
1180
|
+
}
|
|
1181
|
+
return void 0;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
|
|
1185
|
+
* shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
|
|
1186
|
+
* their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
|
|
1187
|
+
* what a role sees).
|
|
1188
|
+
*/
|
|
1189
|
+
viewerOf(request) {
|
|
1190
|
+
if (isLoopback(request.socket.remoteAddress)) return { role: "owner", user: this.options.owner };
|
|
1191
|
+
const guest = this.lanServers.size ? this.sharing.guest(this.cookie(request)) : null;
|
|
1192
|
+
if (!guest) return null;
|
|
1193
|
+
this.sharing.seen(guest);
|
|
1194
|
+
return { role: guest.role, user: guestUser(guest), guest };
|
|
1195
|
+
}
|
|
1196
|
+
/** Keeps track of a stream or socket `viewer` holds open; call the returned function when it closes. */
|
|
1197
|
+
attach(viewer, close) {
|
|
1198
|
+
const live = { viewer, close };
|
|
1199
|
+
this.live.add(live);
|
|
1200
|
+
return () => this.live.delete(live);
|
|
1201
|
+
}
|
|
1202
|
+
/** `attach` for an HTTP stream (server-sent events): it ends when its person loses access. */
|
|
1203
|
+
hold(viewer, response) {
|
|
1204
|
+
const detach = this.attach(viewer, () => response.end());
|
|
1205
|
+
response.on("close", detach);
|
|
1206
|
+
}
|
|
1207
|
+
/** Still allowed in: the owner, or a guest whose link is on and who was not removed. */
|
|
1208
|
+
stillIn(viewer) {
|
|
1209
|
+
return !viewer.guest || this.shared && this.sharing.valid(viewer.guest);
|
|
1210
|
+
}
|
|
1211
|
+
/** Ends the streams of guests who lost access (a link turned off or reset, a guest removed). */
|
|
1212
|
+
dropRevoked() {
|
|
1213
|
+
for (const live of [...this.live]) if (live.viewer.guest && !this.sharing.valid(live.viewer.guest)) live.close("revoked");
|
|
1214
|
+
}
|
|
1215
|
+
me(viewer) {
|
|
1216
|
+
return { role: viewer.role, id: viewer.user.id, name: viewer.user.name, email: viewer.user.email };
|
|
1217
|
+
}
|
|
1218
|
+
// ---- routes ----------------------------------------------------------------------------------------------------
|
|
1219
|
+
/** Answers the share link and /__share paths; false for any other path. */
|
|
1220
|
+
async route(request, response) {
|
|
1221
|
+
const p = new URL(request.url ?? "/", "http://local").pathname;
|
|
1222
|
+
const method = request.method ?? "GET";
|
|
1223
|
+
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1224
|
+
const ours = link || p === "/__share" || p.startsWith("/__share/");
|
|
1225
|
+
if (!ours) return false;
|
|
1226
|
+
if (method === "POST" && !sameOrigin(request)) {
|
|
1227
|
+
request.resume();
|
|
1228
|
+
sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
if (link) {
|
|
1232
|
+
if (method === "GET" || method === "POST") await this.join(request, response, method, link[1]);
|
|
1233
|
+
else response.writeHead(405, { Allow: "GET, POST" }).end();
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
const viewer = this.viewerOf(request);
|
|
1237
|
+
if (!viewer) {
|
|
1238
|
+
this.denied(request, response);
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
|
|
1242
|
+
if (method === "GET" && p === "/__share/boot.js") {
|
|
1243
|
+
const body = this.bootScript(viewer);
|
|
1244
|
+
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);
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
|
|
1248
|
+
if (method === "GET" && p === "/__share/ui.js") {
|
|
1249
|
+
fs5.stat(this.options.uiFile, (error, stat) => {
|
|
1250
|
+
if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
|
|
1251
|
+
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" });
|
|
1252
|
+
});
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
if (viewer.role !== "owner") return sendJson2(response, 403, { code: "not_granted", message: "Only the owner can do this." }), true;
|
|
1256
|
+
if (p === "/__share") return await this.shareApi(request, response, method), true;
|
|
1257
|
+
sendJson2(response, 404, { code: "not_found", message: "Not found" });
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
/** The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). */
|
|
1261
|
+
headTags(_viewer) {
|
|
1262
|
+
let tags = '<script src="/__share/boot.js"></script>';
|
|
1263
|
+
if (fs5.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
|
|
1264
|
+
return tags;
|
|
1265
|
+
}
|
|
1266
|
+
/** `html` with the head tags first in its <head>; null when it has none. */
|
|
1267
|
+
injectHead(html, viewer) {
|
|
1268
|
+
const at = html.search(/<head(\s[^>]*)?>/i);
|
|
1269
|
+
if (at < 0) return null;
|
|
1270
|
+
const end = html.indexOf(">", at) + 1;
|
|
1271
|
+
return html.slice(0, end) + this.headTags(viewer) + html.slice(end);
|
|
1272
|
+
}
|
|
1273
|
+
/** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
|
|
1274
|
+
bootScript(viewer) {
|
|
1275
|
+
const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
|
|
1276
|
+
let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
|
|
1277
|
+
for (const [name, value] of Object.entries(this.options.globals?.(viewer) ?? {})) js += `window[${script(name)}]=${script(value)};`;
|
|
1278
|
+
if (viewer.guest) js += GUEST_WATCH;
|
|
1279
|
+
return js;
|
|
1280
|
+
}
|
|
1281
|
+
/** Nobody's request: a page for a browser's navigation, JSON for anything else. */
|
|
1282
|
+
denied(request, response) {
|
|
1283
|
+
request.resume();
|
|
1284
|
+
const navigation = (request.method === "GET" || request.method === "HEAD") && /\btext\/html\b/.test(request.headers.accept ?? "");
|
|
1285
|
+
if (!navigation) return sendJson2(response, 403, { code: "not_granted", message: `This ${this.options.noun.en} is not shared with you.` });
|
|
1286
|
+
const t = this.words(request);
|
|
1287
|
+
this.sendPage(response, 403, t.noAccessTitle, `<h1>${esc(t.noAccessTitle)}</h1><p>${esc(t.noAccess)}</p>${this.ownerHint(request, t)}`);
|
|
1288
|
+
}
|
|
1289
|
+
/** A small page of our own (the join form, the refusal), in the reader's language. */
|
|
1290
|
+
sendPage(response, status, title, body, headers = {}) {
|
|
1291
|
+
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>`;
|
|
1292
|
+
response.writeHead(status, {
|
|
1293
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1294
|
+
"Cache-Control": "no-store",
|
|
1295
|
+
"X-Content-Type-Options": "nosniff",
|
|
1296
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
1297
|
+
...headers
|
|
1298
|
+
}).end(html);
|
|
1299
|
+
}
|
|
1300
|
+
/** On the computer that shares the artifact, over its LAN address: the way back to the owner's page. */
|
|
1301
|
+
ownerHint(request, t) {
|
|
1302
|
+
const from = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
1303
|
+
if (!this.lanServers.has(from)) return "";
|
|
1304
|
+
const url = `http://127.0.0.1:${this.port}/`;
|
|
1305
|
+
return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
|
|
1306
|
+
}
|
|
1307
|
+
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
1308
|
+
async join(request, response, method, token2) {
|
|
1309
|
+
const t = this.words(request);
|
|
1310
|
+
const role = this.sharing.linkRole(token2);
|
|
1311
|
+
const local = isLoopback(request.socket.remoteAddress);
|
|
1312
|
+
const form = method === "POST" ? await readBody(request, 8 << 10).then((body) => body.toString("utf8"), () => "") : "";
|
|
1313
|
+
const title = this.options.title();
|
|
1314
|
+
if (!role || !local && !this.lanServers.size) return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
|
|
1315
|
+
if (local) {
|
|
1316
|
+
const lan = [...this.lanServers.keys()][0];
|
|
1317
|
+
const there = lan ? `http://${lan}:${this.port}/s/${token2}` : "";
|
|
1318
|
+
const tryIt = there ? `<p>${esc(t.tryRole(t.roles[role]))} <a href="${esc(there)}">${esc(there)}</a></p>` : "";
|
|
1319
|
+
return this.sendPage(response, 200, title, `<h1>${esc(title)}</h1><p>${esc(t.youOwn)} <a href="/">${esc(t.openAsOwner)}</a></p>${tryIt}`);
|
|
1320
|
+
}
|
|
1321
|
+
const current = this.sharing.guest(this.cookie(request));
|
|
1322
|
+
if (method === "GET" && current && current.link === this.sharing.state.links[role].token) {
|
|
1323
|
+
response.writeHead(303, { Location: "/", "Cache-Control": "no-store" }).end();
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (method === "GET") {
|
|
1327
|
+
return this.sendPage(
|
|
1328
|
+
response,
|
|
1329
|
+
200,
|
|
1330
|
+
title,
|
|
1331
|
+
`<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>`
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
const name = new URLSearchParams(form).get("name") ?? "";
|
|
1335
|
+
const guest = this.sharing.join(role, name);
|
|
1336
|
+
this.options.log(`share: ${guest.name} joined as ${role} (${request.socket.remoteAddress})`);
|
|
1337
|
+
const cookie = `${this.options.cookieName}=${guest.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
1338
|
+
response.writeHead(303, { Location: "/", "Set-Cookie": cookie, "Cache-Control": "no-store" }).end();
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* 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
|
|
1342
|
+
* the address they came by (`host`, a Host header hostAllowed let in). A guest could forward the link they joined
|
|
1343
|
+
* with anyway; a lower one gives no more than that. Who joins through one shows in the owner's panel like anyone else.
|
|
1344
|
+
*/
|
|
1345
|
+
invites(viewer, host) {
|
|
1346
|
+
const { links } = this.sharing.state;
|
|
1347
|
+
const from = viewer.role === "owner" ? 0 : GUEST_ROLES.indexOf(viewer.role);
|
|
1348
|
+
return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}/s/${links[role].token}` })) };
|
|
1349
|
+
}
|
|
1350
|
+
/** Links, addresses and people, for the share panel and `artifact.mjs share`. */
|
|
1351
|
+
shareView() {
|
|
1352
|
+
const addresses = [...this.lanServers.keys()];
|
|
1353
|
+
const base = addresses.length ? `http://${addresses[0]}:${this.port}` : `http://127.0.0.1:${this.port}`;
|
|
1354
|
+
const online = /* @__PURE__ */ new Set();
|
|
1355
|
+
for (const live of this.live) if (live.viewer.guest) online.add(live.viewer.guest.id);
|
|
1356
|
+
const { links, guests } = this.sharing.state;
|
|
1357
|
+
return {
|
|
1358
|
+
title: this.options.title(),
|
|
1359
|
+
lan: this.lanServers.size > 0,
|
|
1360
|
+
port: this.port,
|
|
1361
|
+
addresses,
|
|
1362
|
+
local: `http://127.0.0.1:${this.port}/`,
|
|
1363
|
+
links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}/s/${links[role].token}` }])),
|
|
1364
|
+
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) }))
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
|
|
1369
|
+
* who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
|
|
1370
|
+
* {guest}. Answers the view after.
|
|
1371
|
+
*/
|
|
1372
|
+
async share(action) {
|
|
1373
|
+
const role = action.role;
|
|
1374
|
+
switch (action.action) {
|
|
1375
|
+
case "lan": {
|
|
1376
|
+
const addresses = await this.setLan(action.on === true);
|
|
1377
|
+
if (action.on === true && !addresses.length) throw new ShareError("This computer has no local network address to share on.");
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "link":
|
|
1381
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1382
|
+
this.sharing.setLink(role, action.on === true);
|
|
1383
|
+
break;
|
|
1384
|
+
case "reset":
|
|
1385
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1386
|
+
this.sharing.resetLink(role);
|
|
1387
|
+
break;
|
|
1388
|
+
case "role": {
|
|
1389
|
+
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1390
|
+
if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
|
|
1391
|
+
if (!this.sharing.setRole(action.guest, role)) throw new ShareError(`The ${role} link is off: turn it on first.`);
|
|
1392
|
+
for (const live of [...this.live]) if (live.viewer.guest?.id === action.guest) live.close("revoked");
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
case "remove":
|
|
1396
|
+
if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
|
|
1397
|
+
break;
|
|
1398
|
+
default:
|
|
1399
|
+
throw new ShareError("action: lan, link, reset, role or remove");
|
|
1400
|
+
}
|
|
1401
|
+
this.options.log(`share: ${JSON.stringify(action)}`);
|
|
1402
|
+
this.dropRevoked();
|
|
1403
|
+
return this.shareView();
|
|
1404
|
+
}
|
|
1405
|
+
async shareApi(request, response, method) {
|
|
1406
|
+
if (method === "GET") return sendJson2(response, 200, this.shareView());
|
|
1407
|
+
if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: `${method} is not allowed here.` });
|
|
1408
|
+
let action;
|
|
1409
|
+
try {
|
|
1410
|
+
action = JSON.parse((await readBody(request, 64 << 10)).toString("utf8"));
|
|
1411
|
+
} catch {
|
|
1412
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
|
|
1413
|
+
}
|
|
1414
|
+
if (!isRecord3(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
|
|
1415
|
+
try {
|
|
1416
|
+
return sendJson2(response, 200, await this.share(action));
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
if (error instanceof ShareError) return sendJson2(response, 400, { code: "invalid_argument", message: error.message });
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
words(request) {
|
|
1423
|
+
const zh = /^zh\b/i.test(request.headers["accept-language"] ?? "");
|
|
1424
|
+
return (zh ? ZH : EN)(this.options.noun);
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
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};";
|
|
1428
|
+
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);";
|
|
1429
|
+
var PAGE_CSS = `
|
|
1430
|
+
:root { color-scheme: light dark; --bg: #f7f6f3; --card: #fff; --fg: #1f1e1d; --muted: #6b6a66; --line: #e3e1dc; --accent: #c96442; }
|
|
1431
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #1f1e1d; --card: #2a2927; --fg: #f2f1ee; --muted: #a8a6a0; --line: #3a3936; } }
|
|
1432
|
+
* { box-sizing: border-box; }
|
|
1433
|
+
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; }
|
|
1434
|
+
main { width: min(420px, 100%); background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 28px; }
|
|
1435
|
+
h1 { font-size: 20px; margin: 0 0 8px; word-break: break-word; }
|
|
1436
|
+
p { margin: 0 0 16px; color: var(--muted); }
|
|
1437
|
+
label { display: block; font-size: 13px; margin: 0 0 6px; color: var(--muted); }
|
|
1438
|
+
input { width: 100%; font: inherit; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit; margin: 0 0 14px; }
|
|
1439
|
+
button { width: 100%; font: inherit; font-weight: 600; padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
|
|
1440
|
+
.note { font-size: 12px; margin: 14px 0 0; }
|
|
1441
|
+
a { color: var(--accent); overflow-wrap: anywhere; }
|
|
1442
|
+
`;
|
|
1443
|
+
var EN = (noun) => ({
|
|
1444
|
+
noAccessTitle: "No access",
|
|
1445
|
+
noAccess: `This ${noun.en} is not shared with you. Ask its owner for a link.`,
|
|
1446
|
+
linkGoneTitle: "This link does not work",
|
|
1447
|
+
linkGone: `The link was turned off or replaced, or the ${noun.en} is no longer shared. Ask its owner for a new one.`,
|
|
1448
|
+
invited: (what) => `You were invited to ${what} this ${noun.en}.`,
|
|
1449
|
+
yourName: "Your name (others see it on your comments and edits)",
|
|
1450
|
+
open: `Open the ${noun.en}`,
|
|
1451
|
+
nameNote: "This browser keeps your place; the owner can remove it at any time.",
|
|
1452
|
+
roles: { editor: "edit", commenter: "comment on", viewer: "view" },
|
|
1453
|
+
youOwn: `This is your ${noun.en}: on this computer you are its owner.`,
|
|
1454
|
+
openAsOwner: "Open it",
|
|
1455
|
+
tryRole: (what) => `To see it as someone invited to ${what} it does, open the link at the network address, here or on another device:`,
|
|
1456
|
+
ownerHere: `You are on the computer that shares this ${noun.en}. Open it as the owner at`
|
|
1457
|
+
});
|
|
1458
|
+
var ZH = (noun) => ({
|
|
1459
|
+
noAccessTitle: "\u65E0\u6743\u8BBF\u95EE",
|
|
1460
|
+
noAccess: `${noun.zhThis}\u6CA1\u6709\u5206\u4EAB\u7ED9\u4F60\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u94FE\u63A5\u3002`,
|
|
1461
|
+
linkGoneTitle: "\u94FE\u63A5\u5DF2\u5931\u6548",
|
|
1462
|
+
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`,
|
|
1463
|
+
invited: (what) => `\u4F60\u88AB\u9080\u8BF7${what}${noun.zhThis}\u3002`,
|
|
1464
|
+
yourName: "\u4F60\u7684\u540D\u5B57\uFF08\u522B\u4EBA\u4F1A\u5728\u4F60\u7684\u8BC4\u8BBA\u548C\u7F16\u8F91\u4E0A\u770B\u5230\u5B83\uFF09",
|
|
1465
|
+
open: `\u6253\u5F00${noun.zh}`,
|
|
1466
|
+
nameNote: "\u8FD9\u4E2A\u6D4F\u89C8\u5668\u4F1A\u8BB0\u4F4F\u4F60\u7684\u8EAB\u4EFD\uFF1B\u6240\u6709\u8005\u53EF\u4EE5\u968F\u65F6\u79FB\u9664\u3002",
|
|
1467
|
+
roles: { editor: "\u7F16\u8F91", commenter: "\u8BC4\u8BBA", viewer: "\u67E5\u770B" },
|
|
1468
|
+
youOwn: `\u8FD9\u662F\u4F60\u7684${noun.zh}\uFF1A\u5728\u8FD9\u53F0\u7535\u8111\u4E0A\u4F60\u662F\u6240\u6709\u8005\u3002`,
|
|
1469
|
+
openAsOwner: `\u6253\u5F00${noun.zh}`,
|
|
1470
|
+
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`,
|
|
1471
|
+
ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
|
|
1472
|
+
});
|
|
1473
|
+
|
|
816
1474
|
var KIND = "";
|
|
817
1475
|
var NOUN = "";
|
|
818
1476
|
var INDEX_FILE = "";
|
|
@@ -903,7 +1561,8 @@ var SSE_KEEPALIVE_MS2 = 25e3;
|
|
|
903
1561
|
var WATCH_DEBOUNCE_MS2 = 300;
|
|
904
1562
|
var POLL_MS2 = 2e3;
|
|
905
1563
|
var CHANGE_LOG_SIZE = 64;
|
|
906
|
-
var
|
|
1564
|
+
var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
1565
|
+
var sha256 = (bytes) => createHash2("sha256").update(bytes).digest("hex");
|
|
907
1566
|
function contentTypeOf(file) {
|
|
908
1567
|
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
909
1568
|
return MIME[ext] ?? "application/octet-stream";
|
|
@@ -951,7 +1610,7 @@ async function collectFiles(root, limit) {
|
|
|
951
1610
|
const seen = /* @__PURE__ */ new Set();
|
|
952
1611
|
let truncated = false;
|
|
953
1612
|
const visit = async (relDir, descend) => {
|
|
954
|
-
const dir = relDir === "" ? root :
|
|
1613
|
+
const dir = relDir === "" ? root : path5.join(root, ...relDir.split("/"));
|
|
955
1614
|
let entries;
|
|
956
1615
|
try {
|
|
957
1616
|
entries = await fsp2.readdir(dir, { withFileTypes: true });
|
|
@@ -982,8 +1641,8 @@ async function collectFiles(root, limit) {
|
|
|
982
1641
|
return { rels, truncated };
|
|
983
1642
|
}
|
|
984
1643
|
async function writeFileAtomic(file, bytes) {
|
|
985
|
-
await fsp2.mkdir(
|
|
986
|
-
const tmp =
|
|
1644
|
+
await fsp2.mkdir(path5.dirname(file), { recursive: true });
|
|
1645
|
+
const tmp = path5.join(path5.dirname(file), `.${path5.basename(file)}.${randomUUID()}.tmp`);
|
|
987
1646
|
try {
|
|
988
1647
|
await fsp2.writeFile(tmp, bytes);
|
|
989
1648
|
await fsp2.rename(tmp, file);
|
|
@@ -1025,11 +1684,11 @@ var Store = class {
|
|
|
1025
1684
|
async diskPath(rel) {
|
|
1026
1685
|
const realRoot = await fsp2.realpath(this.root).catch(() => null);
|
|
1027
1686
|
if (realRoot === null) return null;
|
|
1028
|
-
const file =
|
|
1029
|
-
for (let dir =
|
|
1687
|
+
const file = path5.join(realRoot, ...rel.split("/"));
|
|
1688
|
+
for (let dir = path5.dirname(file); ; dir = path5.dirname(dir)) {
|
|
1030
1689
|
const real = await fsp2.realpath(dir).catch(() => null);
|
|
1031
1690
|
if (real === null) {
|
|
1032
|
-
if (
|
|
1691
|
+
if (path5.dirname(dir) === dir) return null;
|
|
1033
1692
|
continue;
|
|
1034
1693
|
}
|
|
1035
1694
|
if (real !== dir) return null;
|
|
@@ -1142,7 +1801,7 @@ var Store = class {
|
|
|
1142
1801
|
}
|
|
1143
1802
|
async findBlobFile(id) {
|
|
1144
1803
|
try {
|
|
1145
|
-
const names = await fsp2.readdir(
|
|
1804
|
+
const names = await fsp2.readdir(path5.join(this.root, LAYOUT.blobDir));
|
|
1146
1805
|
const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
|
|
1147
1806
|
if (name === void 0) return null;
|
|
1148
1807
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
|
|
@@ -1197,13 +1856,13 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1197
1856
|
const directories = /* @__PURE__ */ new Map();
|
|
1198
1857
|
const visit = (rel, descend) => {
|
|
1199
1858
|
if (directories.size >= maxDirectories) return;
|
|
1200
|
-
const dir = rel === "" ? store.root :
|
|
1201
|
-
if (directories.has(dir) || !
|
|
1202
|
-
const info =
|
|
1859
|
+
const dir = rel === "" ? store.root : path5.join(store.root, ...rel.split("/"));
|
|
1860
|
+
if (directories.has(dir) || !fs6.existsSync(dir)) return;
|
|
1861
|
+
const info = fs6.lstatSync(dir);
|
|
1203
1862
|
if (!info.isDirectory()) return;
|
|
1204
1863
|
directories.set(dir, `${info.dev}:${info.ino}`);
|
|
1205
1864
|
if (!descend) return;
|
|
1206
|
-
for (const entry of
|
|
1865
|
+
for (const entry of fs6.readdirSync(dir, { withFileTypes: true })) {
|
|
1207
1866
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
1208
1867
|
const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
1209
1868
|
if (LAYOUT.descendInto(child)) visit(child, true);
|
|
@@ -1220,19 +1879,19 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1220
1879
|
if (watchers.has(dir)) continue;
|
|
1221
1880
|
let watcher;
|
|
1222
1881
|
try {
|
|
1223
|
-
watcher =
|
|
1882
|
+
watcher = fs6.watch(dir, (event, name) => {
|
|
1224
1883
|
if (stopped) return;
|
|
1225
1884
|
if (name) {
|
|
1226
|
-
const changed =
|
|
1885
|
+
const changed = path5.join(dir, String(name));
|
|
1227
1886
|
if (event === "rename" && watchers.has(changed)) {
|
|
1228
1887
|
for (const [watchedDir, item] of watchers) {
|
|
1229
|
-
if (watchedDir === changed || watchedDir.startsWith(changed +
|
|
1888
|
+
if (watchedDir === changed || watchedDir.startsWith(changed + path5.sep)) {
|
|
1230
1889
|
item.watcher.close();
|
|
1231
1890
|
watchers.delete(watchedDir);
|
|
1232
1891
|
}
|
|
1233
1892
|
}
|
|
1234
1893
|
}
|
|
1235
|
-
const rel =
|
|
1894
|
+
const rel = path5.relative(store.root, changed).split(path5.sep).join("/");
|
|
1236
1895
|
if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
|
|
1237
1896
|
}
|
|
1238
1897
|
schedule();
|
|
@@ -1266,7 +1925,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1266
1925
|
const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
|
|
1267
1926
|
const parts = [];
|
|
1268
1927
|
for (const rel of rels) {
|
|
1269
|
-
const info = await fsp2.lstat(
|
|
1928
|
+
const info = await fsp2.lstat(path5.join(store.root, ...rel.split("/"))).catch(() => null);
|
|
1270
1929
|
parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
|
|
1271
1930
|
}
|
|
1272
1931
|
return parts.join("\n");
|
|
@@ -1295,23 +1954,39 @@ function watchArtifact(store, onChange, onError) {
|
|
|
1295
1954
|
};
|
|
1296
1955
|
}
|
|
1297
1956
|
var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
|
|
1298
|
-
async function startServer({ root, port =
|
|
1299
|
-
`), agentHook }) {
|
|
1300
|
-
const project =
|
|
1957
|
+
async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
|
|
1958
|
+
`), agentHook, lan = false, portGiven = false }) {
|
|
1959
|
+
const project = path5.join(root, "project");
|
|
1301
1960
|
const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
|
|
1302
1961
|
const projectDir = await fsp2.realpath(project);
|
|
1303
1962
|
const store = new Store(projectDir, stamp);
|
|
1963
|
+
const owner = localOwner();
|
|
1964
|
+
const share = new ShareHost({
|
|
1965
|
+
root,
|
|
1966
|
+
cookieName: guestCookieName(KIND, root),
|
|
1967
|
+
owner,
|
|
1968
|
+
noun: NOUNS[KIND] ?? { en: NOUN, zh: NOUN, zhThis: NOUN },
|
|
1969
|
+
title: () => artifactTitle(root, projectDir),
|
|
1970
|
+
log: stamp,
|
|
1971
|
+
createServer: () => http.createServer(handle),
|
|
1972
|
+
uiFile: path5.join(EDITOR_DIR, "share-ui.js")
|
|
1973
|
+
});
|
|
1304
1974
|
const comments = createCommentsService({
|
|
1305
1975
|
root,
|
|
1306
1976
|
log: stamp,
|
|
1307
1977
|
locate: commentLocation ? (input) => commentLocation(input, projectDir) : void 0,
|
|
1308
1978
|
agentHook,
|
|
1309
|
-
|
|
1979
|
+
userName: owner.name,
|
|
1980
|
+
allowWrite: sameOrigin,
|
|
1981
|
+
viewer: (request) => {
|
|
1982
|
+
const viewer = share.viewerOf(request);
|
|
1983
|
+
return viewer ? commentsViewer(viewer) : null;
|
|
1984
|
+
}
|
|
1310
1985
|
});
|
|
1311
1986
|
const grant = randomBytes(24).toString("base64url");
|
|
1312
1987
|
const editorPath = `/${KIND}/${grant}/`;
|
|
1313
1988
|
const boot = randomBytes(6).toString("hex");
|
|
1314
|
-
const clients = /* @__PURE__ */ new
|
|
1989
|
+
const clients = /* @__PURE__ */ new Map();
|
|
1315
1990
|
const changes = [];
|
|
1316
1991
|
let changeSeq = 0;
|
|
1317
1992
|
const pageCache = /* @__PURE__ */ new Map();
|
|
@@ -1321,18 +1996,23 @@ event: ${event.type}
|
|
|
1321
1996
|
data: ${JSON.stringify(event)}
|
|
1322
1997
|
|
|
1323
1998
|
`;
|
|
1999
|
+
const changed = (event, from) => {
|
|
2000
|
+
changeSeq += 1;
|
|
2001
|
+
changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {} });
|
|
2002
|
+
if (changes.length > CHANGE_LOG_SIZE) changes.shift();
|
|
2003
|
+
for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
|
|
2004
|
+
};
|
|
1324
2005
|
const stopWatch = watchArtifact(
|
|
1325
2006
|
store,
|
|
1326
2007
|
(event) => {
|
|
1327
|
-
changeSeq += 1;
|
|
1328
|
-
changes.push({ seq: changeSeq, paths: event.paths });
|
|
1329
|
-
if (changes.length > CHANGE_LOG_SIZE) changes.shift();
|
|
1330
2008
|
stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
|
|
1331
|
-
|
|
2009
|
+
changed(event);
|
|
1332
2010
|
},
|
|
1333
2011
|
(error) => stamp(`watching failed: ${errorText(error)}`)
|
|
1334
2012
|
);
|
|
1335
|
-
const events = async (request, response) => {
|
|
2013
|
+
const events = async (request, response, viewer) => {
|
|
2014
|
+
const given = new URL(request.url ?? "/", "http://local").searchParams.get("client") ?? "";
|
|
2015
|
+
const client = CLIENT_ID.test(given) ? given : "";
|
|
1336
2016
|
response.writeHead(200, {
|
|
1337
2017
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1338
2018
|
"Cache-Control": "no-store",
|
|
@@ -1342,16 +2022,18 @@ data: ${JSON.stringify(event)}
|
|
|
1342
2022
|
response.write(": connected\n\n");
|
|
1343
2023
|
const last = /^([0-9a-f]+)\.(\d+)$/.exec(String(request.headers["last-event-id"] ?? ""));
|
|
1344
2024
|
const missedSince = last && last[1] === boot ? Number(last[2]) : null;
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
2025
|
+
const truncated = missedSince !== null && changes.length > 0 && changes[0].seq > missedSince + 1;
|
|
2026
|
+
const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
|
|
2027
|
+
if (missed.length) {
|
|
2028
|
+
const paths = [...new Set(missed.flatMap((c) => c.paths))];
|
|
1348
2029
|
response.write(frame({ type: "external-change", paths, ver: (await store.listing()).ver }));
|
|
1349
2030
|
} else {
|
|
1350
2031
|
response.write(`id: ${eventId()}
|
|
1351
2032
|
|
|
1352
2033
|
`);
|
|
1353
2034
|
}
|
|
1354
|
-
clients.
|
|
2035
|
+
clients.set(response, client);
|
|
2036
|
+
share.hold(viewer, response);
|
|
1355
2037
|
const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
|
|
1356
2038
|
keepAlive.unref?.();
|
|
1357
2039
|
const done = () => {
|
|
@@ -1381,11 +2063,11 @@ data: ${JSON.stringify(event)}
|
|
|
1381
2063
|
"Content-Security-Policy": DATA_CSP
|
|
1382
2064
|
});
|
|
1383
2065
|
};
|
|
1384
|
-
const serveEditor = async (request, response, rest) => {
|
|
2066
|
+
const serveEditor = async (request, response, rest, viewer) => {
|
|
1385
2067
|
const parts = rest === "" ? ["index.html"] : rest.split("/");
|
|
1386
2068
|
const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
|
|
1387
2069
|
const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
|
|
1388
|
-
const file = realDist === null ? null :
|
|
2070
|
+
const file = realDist === null ? null : path5.join(realDist, ...parts);
|
|
1389
2071
|
const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
|
|
1390
2072
|
const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
|
|
1391
2073
|
if (file === null || real !== file || !info?.isFile()) {
|
|
@@ -1396,25 +2078,26 @@ data: ${JSON.stringify(event)}
|
|
|
1396
2078
|
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
1397
2079
|
return;
|
|
1398
2080
|
}
|
|
1399
|
-
const ext =
|
|
2081
|
+
const ext = path5.extname(file).toLowerCase();
|
|
1400
2082
|
const type = STATIC_TYPES[ext] ?? "application/octet-stream";
|
|
1401
2083
|
if (ext === ".html") {
|
|
1402
2084
|
const key = parts.join("/");
|
|
1403
2085
|
let page = pageCache.get(key);
|
|
1404
2086
|
if (!page || page.mtimeMs !== info.mtimeMs) {
|
|
1405
2087
|
const html = await fsp2.readFile(file, "utf8");
|
|
1406
|
-
page = { mtimeMs: info.mtimeMs,
|
|
2088
|
+
page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
|
|
1407
2089
|
pageCache.set(key, page);
|
|
1408
2090
|
}
|
|
2091
|
+
const body = Buffer.from(share.injectHead(page.html, viewer) ?? page.html);
|
|
1409
2092
|
response.writeHead(200, {
|
|
1410
2093
|
"Content-Type": type,
|
|
1411
|
-
"Content-Length":
|
|
2094
|
+
"Content-Length": body.length,
|
|
1412
2095
|
"Cache-Control": "no-cache",
|
|
1413
2096
|
"X-Content-Type-Options": "nosniff",
|
|
1414
2097
|
"Referrer-Policy": "no-referrer",
|
|
1415
2098
|
"Content-Security-Policy": page.csp
|
|
1416
2099
|
});
|
|
1417
|
-
response.end(request.method === "HEAD" ? void 0 :
|
|
2100
|
+
response.end(request.method === "HEAD" ? void 0 : body);
|
|
1418
2101
|
return;
|
|
1419
2102
|
}
|
|
1420
2103
|
sendFile(request, response, file, {
|
|
@@ -1445,6 +2128,10 @@ data: ${JSON.stringify(event)}
|
|
|
1445
2128
|
const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
|
|
1446
2129
|
stamp(`saved from the page (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""}): ${names.join(", ")}`);
|
|
1447
2130
|
sendJson2(response, outcome.status, outcome.body);
|
|
2131
|
+
if (outcome.status === 200 && clients.size > 0) {
|
|
2132
|
+
const from = String(request.headers["x-deck-client"] ?? "");
|
|
2133
|
+
changed({ type: "external-change", paths: Object.keys(files), ver: String(outcome.body.version ?? "") }, CLIENT_ID.test(from) ? from : void 0);
|
|
2134
|
+
}
|
|
1448
2135
|
};
|
|
1449
2136
|
const upload = async (request, response) => {
|
|
1450
2137
|
const contentType = request.headers["content-type"] ?? "application/octet-stream";
|
|
@@ -1458,9 +2145,18 @@ data: ${JSON.stringify(event)}
|
|
|
1458
2145
|
stamp(`uploaded from the page: ${result.url} (${contentType}, ${bytes.length} bytes)`);
|
|
1459
2146
|
sendJson2(response, 200, result);
|
|
1460
2147
|
};
|
|
1461
|
-
const
|
|
2148
|
+
const session = (viewer) => {
|
|
2149
|
+
const extras = sessionExtras();
|
|
2150
|
+
if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
|
|
2151
|
+
return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
|
|
2152
|
+
};
|
|
2153
|
+
const route = async (request, response, rest, viewer) => {
|
|
1462
2154
|
const method = request.method ?? "GET";
|
|
1463
|
-
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`))
|
|
2155
|
+
if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
|
|
2156
|
+
const sub = rest.slice(COMMENTS_ROUTE.length);
|
|
2157
|
+
if (sub === "/events") share.hold(viewer, response);
|
|
2158
|
+
return comments.handle(request, response, sub);
|
|
2159
|
+
}
|
|
1464
2160
|
if (rest === ROUTES.publish || rest === ROUTES.assets) {
|
|
1465
2161
|
if (method !== "POST") {
|
|
1466
2162
|
response.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -1471,6 +2167,11 @@ data: ${JSON.stringify(event)}
|
|
|
1471
2167
|
publishError(response, 403, "not_writer", "cross-origin request");
|
|
1472
2168
|
return;
|
|
1473
2169
|
}
|
|
2170
|
+
if (!canEditAs(viewer.role)) {
|
|
2171
|
+
request.resume();
|
|
2172
|
+
publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
1474
2175
|
if (rest === ROUTES.publish) await publish(request, response);
|
|
1475
2176
|
else await upload(request, response);
|
|
1476
2177
|
return;
|
|
@@ -1480,8 +2181,8 @@ data: ${JSON.stringify(event)}
|
|
|
1480
2181
|
return;
|
|
1481
2182
|
}
|
|
1482
2183
|
if (rest === ROUTES.listing) return sendJson2(response, 200, await store.listing());
|
|
1483
|
-
if (rest === ROUTES.session) return sendJson2(response, 200,
|
|
1484
|
-
if (rest === ROUTES.events) return events(request, response);
|
|
2184
|
+
if (rest === ROUTES.session) return sendJson2(response, 200, session(viewer));
|
|
2185
|
+
if (rest === ROUTES.events) return events(request, response, viewer);
|
|
1485
2186
|
if (rest.startsWith("project/")) {
|
|
1486
2187
|
const bytes = await store.readProjectFile(rest);
|
|
1487
2188
|
if (bytes === null) {
|
|
@@ -1500,7 +2201,7 @@ data: ${JSON.stringify(event)}
|
|
|
1500
2201
|
}
|
|
1501
2202
|
const blob = BLOB_PATH.exec(rest);
|
|
1502
2203
|
if (blob) return serveBlob(request, response, blob[1]);
|
|
1503
|
-
return serveEditor(request, response, rest);
|
|
2204
|
+
return serveEditor(request, response, rest, viewer);
|
|
1504
2205
|
};
|
|
1505
2206
|
const guard = async (response, work) => {
|
|
1506
2207
|
try {
|
|
@@ -1515,17 +2216,25 @@ data: ${JSON.stringify(event)}
|
|
|
1515
2216
|
else response.destroy();
|
|
1516
2217
|
}
|
|
1517
2218
|
};
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
|
|
2219
|
+
async function handle(request, response) {
|
|
2220
|
+
if (!share.hostAllowed(request)) {
|
|
2221
|
+
request.resume();
|
|
2222
|
+
response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
|
|
1523
2223
|
return;
|
|
1524
2224
|
}
|
|
2225
|
+
const url2 = new URL(request.url ?? "/", "http://localhost");
|
|
2226
|
+
const pathname = url2.pathname;
|
|
1525
2227
|
if (pathname === "/favicon.ico") {
|
|
1526
2228
|
response.writeHead(204).end();
|
|
1527
2229
|
return;
|
|
1528
2230
|
}
|
|
2231
|
+
if (await share.route(request, response)) return;
|
|
2232
|
+
const viewer = share.viewerOf(request);
|
|
2233
|
+
if (!viewer) return share.denied(request, response);
|
|
2234
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
2235
|
+
response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
1529
2238
|
const rootBlob = BLOB_PATH.exec(pathname.slice(1));
|
|
1530
2239
|
if (rootBlob) {
|
|
1531
2240
|
void guard(response, () => serveBlob(request, response, rootBlob[1]));
|
|
@@ -1547,30 +2256,41 @@ data: ${JSON.stringify(event)}
|
|
|
1547
2256
|
response.writeHead(400).end();
|
|
1548
2257
|
return;
|
|
1549
2258
|
}
|
|
1550
|
-
void guard(response, () => route(request, response, rest));
|
|
1551
|
-
}
|
|
2259
|
+
void guard(response, () => route(request, response, rest, viewer));
|
|
2260
|
+
}
|
|
2261
|
+
const server2 = http.createServer((request, response) => void guard(response, () => handle(request, response)));
|
|
1552
2262
|
const close = () => {
|
|
1553
2263
|
stopWatch();
|
|
1554
2264
|
comments.close();
|
|
1555
|
-
|
|
2265
|
+
share.close();
|
|
2266
|
+
for (const client of clients.keys()) client.end();
|
|
1556
2267
|
clients.clear();
|
|
1557
2268
|
server2.close();
|
|
1558
2269
|
server2.closeAllConnections?.();
|
|
1559
2270
|
};
|
|
1560
|
-
const actualPort = await listen(server2, port, host).catch((error) => {
|
|
2271
|
+
const actualPort = await share.listen(server2, { port, host, lan, portGiven }).catch((error) => {
|
|
1561
2272
|
close();
|
|
1562
2273
|
throw error;
|
|
1563
2274
|
});
|
|
1564
2275
|
const url = `${serverOrigin(host, actualPort)}${editorPath}`;
|
|
1565
2276
|
stamp(`serving ${project} at ${url}`);
|
|
1566
|
-
|
|
2277
|
+
const lanUrls = share.lanUrls();
|
|
2278
|
+
return { url, port: actualPort, grant, close, ...lanUrls.length ? { lan: lanUrls } : {} };
|
|
2279
|
+
}
|
|
2280
|
+
function artifactTitle(root, projectDir) {
|
|
2281
|
+
try {
|
|
2282
|
+
const index = JSON.parse(fs6.readFileSync(path5.join(projectDir, INDEX_FILE), "utf8"));
|
|
2283
|
+
for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
|
|
2284
|
+
} catch {
|
|
2285
|
+
}
|
|
2286
|
+
return path5.basename(root);
|
|
1567
2287
|
}
|
|
1568
2288
|
var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
|
|
1569
|
-
async function serveForeground({ root, port, host, agentHook }) {
|
|
1570
|
-
const running = await startServer({ root, port, host, agentHook });
|
|
2289
|
+
async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
|
|
2290
|
+
const running = await startServer({ root, port, host, agentHook, lan, portGiven });
|
|
1571
2291
|
const state = { pid: process.pid, url: running.url, port: running.port, root, kind: KIND, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1572
2292
|
recordServer(state, running.close);
|
|
1573
|
-
return state;
|
|
2293
|
+
return { ...state, ...running.lan ? { lan: running.lan } : {} };
|
|
1574
2294
|
}
|
|
1575
2295
|
async function main(argv) {
|
|
1576
2296
|
const positional = [];
|
|
@@ -1579,26 +2299,27 @@ async function main(argv) {
|
|
|
1579
2299
|
const arg = argv[i];
|
|
1580
2300
|
if (!arg.startsWith("--")) positional.push(arg);
|
|
1581
2301
|
else if (arg.includes("=")) flags.set(arg.slice(2, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1));
|
|
2302
|
+
else if (arg === "--lan") flags.set("lan", "true");
|
|
1582
2303
|
else flags.set(arg.slice(2), argv[++i] ?? "");
|
|
1583
2304
|
}
|
|
1584
2305
|
if (positional.length !== 1 || flags.has("help")) {
|
|
1585
2306
|
process.stderr.write(
|
|
1586
|
-
`Usage: node ${
|
|
2307
|
+
`Usage: node ${path5.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
|
|
1587
2308
|
<root> holds project/${INDEX_FILE}.
|
|
1588
2309
|
`
|
|
1589
2310
|
);
|
|
1590
2311
|
return 2;
|
|
1591
2312
|
}
|
|
1592
|
-
let root =
|
|
1593
|
-
if (
|
|
1594
|
-
root =
|
|
2313
|
+
let root = path5.resolve(positional[0]);
|
|
2314
|
+
if (path5.basename(root) === "project" && fs6.existsSync(path5.join(root, INDEX_FILE)) && !fs6.existsSync(path5.join(root, "project"))) {
|
|
2315
|
+
root = path5.dirname(root);
|
|
1595
2316
|
}
|
|
1596
|
-
if (!
|
|
1597
|
-
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${
|
|
2317
|
+
if (!fs6.statSync(path5.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
|
|
2318
|
+
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path5.join(root, "project", INDEX_FILE)} does not exist.` })}
|
|
1598
2319
|
`);
|
|
1599
2320
|
return 2;
|
|
1600
2321
|
}
|
|
1601
|
-
const port = Number(flags.get("port")
|
|
2322
|
+
const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS[KIND];
|
|
1602
2323
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1603
2324
|
process.stderr.write(`${JSON.stringify({ error: "INVALID_PORT", message: "--port is a number from 0 to 65535." })}
|
|
1604
2325
|
`);
|
|
@@ -1611,14 +2332,14 @@ async function main(argv) {
|
|
|
1611
2332
|
return 0;
|
|
1612
2333
|
}
|
|
1613
2334
|
const agentHook = flags.get("agent-hook") || void 0;
|
|
1614
|
-
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook });
|
|
1615
|
-
process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid }, null, 2)}
|
|
2335
|
+
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port") });
|
|
2336
|
+
process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid, ...state.lan ? { lan: state.lan } : {} }, null, 2)}
|
|
1616
2337
|
`);
|
|
1617
2338
|
return -1;
|
|
1618
2339
|
}
|
|
1619
2340
|
function isMain(moduleUrl) {
|
|
1620
2341
|
try {
|
|
1621
|
-
return Boolean(process.argv[1]) && pathToFileURL(
|
|
2342
|
+
return Boolean(process.argv[1]) && pathToFileURL(fs6.realpathSync(process.argv[1])).href === moduleUrl;
|
|
1622
2343
|
} catch {
|
|
1623
2344
|
return false;
|
|
1624
2345
|
}
|
|
@@ -1632,7 +2353,7 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
1632
2353
|
sessionExtras = kind.sessionExtras;
|
|
1633
2354
|
commentLocation = kind.commentLocation;
|
|
1634
2355
|
SCRIPT = fileURLToPath(moduleUrl);
|
|
1635
|
-
EDITOR_DIR =
|
|
2356
|
+
EDITOR_DIR = path5.join(path5.dirname(SCRIPT), "editor");
|
|
1636
2357
|
const runIfMain = () => {
|
|
1637
2358
|
if (!isMain(moduleUrl)) return;
|
|
1638
2359
|
main(process.argv.slice(2)).then(
|
|
@@ -1640,7 +2361,8 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
1640
2361
|
if (code >= 0) process.exitCode = code;
|
|
1641
2362
|
},
|
|
1642
2363
|
(error) => {
|
|
1643
|
-
|
|
2364
|
+
const code = error.code === "PORT_IN_USE" ? "PORT_IN_USE" : "SERVER_FAILED";
|
|
2365
|
+
process.stderr.write(`${JSON.stringify({ error: code, message: errorText(error) })}
|
|
1644
2366
|
`);
|
|
1645
2367
|
process.exitCode = 2;
|
|
1646
2368
|
}
|
|
@@ -1681,7 +2403,7 @@ function commentLocation2(input, projectDir) {
|
|
|
1681
2403
|
});
|
|
1682
2404
|
let boards = [];
|
|
1683
2405
|
try {
|
|
1684
|
-
const listed = JSON.parse(
|
|
2406
|
+
const listed = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE2), "utf8")).boards;
|
|
1685
2407
|
if (listed && typeof listed === "object") boards = Object.keys(listed);
|
|
1686
2408
|
} catch {
|
|
1687
2409
|
}
|