@seedvault/server 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.html +216 -61
- package/dist/server.js +247 -395
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -74,6 +74,33 @@
|
|
|
74
74
|
cursor: pointer;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
#menu-toggle {
|
|
78
|
+
display: none;
|
|
79
|
+
font-size: 18px;
|
|
80
|
+
padding: 4px 10px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#nav-close {
|
|
84
|
+
display: none;
|
|
85
|
+
float: right;
|
|
86
|
+
border: none;
|
|
87
|
+
font-size: 16px;
|
|
88
|
+
padding: 0 4px;
|
|
89
|
+
line-height: 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#backdrop {
|
|
93
|
+
display: none;
|
|
94
|
+
position: fixed;
|
|
95
|
+
inset: 0;
|
|
96
|
+
background: rgba(0, 0, 0, 0.4);
|
|
97
|
+
z-index: 90;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#backdrop.visible {
|
|
101
|
+
display: block;
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
.grid {
|
|
78
105
|
display: flex;
|
|
79
106
|
gap: 0;
|
|
@@ -103,8 +130,32 @@
|
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
@media (max-width: 600px) {
|
|
133
|
+
#menu-toggle {
|
|
134
|
+
display: block;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#nav-close {
|
|
138
|
+
display: block;
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
.grid {
|
|
107
142
|
flex-direction: column;
|
|
143
|
+
position: relative;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#nav {
|
|
147
|
+
position: fixed;
|
|
148
|
+
top: 0;
|
|
149
|
+
left: -100vw;
|
|
150
|
+
width: 100vw !important;
|
|
151
|
+
height: 100%;
|
|
152
|
+
z-index: 100;
|
|
153
|
+
background: Canvas;
|
|
154
|
+
transition: transform 0.2s ease;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#nav.open {
|
|
158
|
+
transform: translateX(100vw);
|
|
108
159
|
}
|
|
109
160
|
|
|
110
161
|
.grid>.panel:first-child {
|
|
@@ -178,17 +229,31 @@
|
|
|
178
229
|
}
|
|
179
230
|
|
|
180
231
|
.tree-row {
|
|
181
|
-
display:
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: baseline;
|
|
182
234
|
width: 100%;
|
|
183
235
|
text-align: left;
|
|
184
236
|
border: none;
|
|
185
237
|
padding: 4px 8px;
|
|
186
238
|
white-space: nowrap;
|
|
187
239
|
overflow: hidden;
|
|
188
|
-
text-overflow: ellipsis;
|
|
189
240
|
cursor: pointer;
|
|
190
241
|
}
|
|
191
242
|
|
|
243
|
+
.tree-row .name {
|
|
244
|
+
overflow: hidden;
|
|
245
|
+
text-overflow: ellipsis;
|
|
246
|
+
flex: 1;
|
|
247
|
+
min-width: 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.tree-row .ctime {
|
|
251
|
+
flex-shrink: 0;
|
|
252
|
+
margin-left: 8px;
|
|
253
|
+
opacity: 0.45;
|
|
254
|
+
font-size: 10px;
|
|
255
|
+
}
|
|
256
|
+
|
|
192
257
|
.tree-row:hover {
|
|
193
258
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
194
259
|
}
|
|
@@ -335,12 +400,14 @@
|
|
|
335
400
|
|
|
336
401
|
<body>
|
|
337
402
|
<div class="top">
|
|
403
|
+
<button id="menu-toggle" aria-label="Toggle navigation">☰</button>
|
|
338
404
|
<input id="token" type="password" placeholder="API key ("sv_...")" />
|
|
339
405
|
<button id="connect">Load</button>
|
|
340
406
|
</div>
|
|
407
|
+
<div id="backdrop"></div>
|
|
341
408
|
<div class="grid">
|
|
342
409
|
<section id="nav" class="panel" style="width:220px">
|
|
343
|
-
<div class="panel-head">Files
|
|
410
|
+
<div class="panel-head">Files<button id="nav-close" aria-label="Close navigation">×</button></div>
|
|
344
411
|
<div class="panel-body" id="nav-body" tabindex="0">
|
|
345
412
|
<ul id="files" class="tree"></ul>
|
|
346
413
|
</div>
|
|
@@ -358,6 +425,7 @@
|
|
|
358
425
|
const { marked } = await import("https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js");
|
|
359
426
|
const matter = (await import("https://cdn.jsdelivr.net/npm/gray-matter@4.0.3/+esm")).default;
|
|
360
427
|
const DOMPurify = (await import("https://cdn.jsdelivr.net/npm/dompurify@3.0.9/+esm")).default;
|
|
428
|
+
const morphdom = (await import("https://cdn.jsdelivr.net/npm/morphdom@2.7.4/+esm")).default;
|
|
361
429
|
|
|
362
430
|
function renderMarkdown(raw) {
|
|
363
431
|
if (typeof raw !== "string") return "";
|
|
@@ -380,6 +448,22 @@
|
|
|
380
448
|
const filesEl = $("files");
|
|
381
449
|
const contentEl = $("content");
|
|
382
450
|
const statusEl = $("status");
|
|
451
|
+
const backdropEl = $("backdrop");
|
|
452
|
+
const menuToggleEl = $("menu-toggle");
|
|
453
|
+
|
|
454
|
+
function closeSidebar() {
|
|
455
|
+
$("nav").classList.remove("open");
|
|
456
|
+
backdropEl.classList.remove("visible");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
menuToggleEl.addEventListener("click", () => {
|
|
460
|
+
const nav = $("nav");
|
|
461
|
+
nav.classList.toggle("open");
|
|
462
|
+
backdropEl.classList.toggle("visible", nav.classList.contains("open"));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
backdropEl.addEventListener("click", closeSidebar);
|
|
466
|
+
$("nav-close").addEventListener("click", closeSidebar);
|
|
383
467
|
|
|
384
468
|
let token = localStorage.getItem("sv-token") || "";
|
|
385
469
|
tokenEl.value = token;
|
|
@@ -404,20 +488,50 @@
|
|
|
404
488
|
const savedContributor = localStorage.getItem("sv-contributor") || "";
|
|
405
489
|
const savedFile = localStorage.getItem("sv-file") || "";
|
|
406
490
|
|
|
407
|
-
function
|
|
491
|
+
function getExpandedKeys() {
|
|
492
|
+
try {
|
|
493
|
+
return new Set(JSON.parse(localStorage.getItem("sv-expanded") || "[]"));
|
|
494
|
+
} catch { return new Set(); }
|
|
495
|
+
}
|
|
496
|
+
function saveExpandedKeys(keys) {
|
|
497
|
+
localStorage.setItem("sv-expanded", JSON.stringify([...keys]));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function buildTree(fileEntries) {
|
|
408
501
|
const root = {};
|
|
409
|
-
for (const
|
|
410
|
-
const parts =
|
|
502
|
+
for (const f of fileEntries) {
|
|
503
|
+
const parts = f.path.split("/");
|
|
411
504
|
let node = root;
|
|
412
505
|
for (const part of parts) {
|
|
413
506
|
if (!node[part]) node[part] = {};
|
|
414
507
|
node = node[part];
|
|
415
508
|
}
|
|
416
|
-
node.__file =
|
|
509
|
+
node.__file = f;
|
|
417
510
|
}
|
|
418
511
|
return root;
|
|
419
512
|
}
|
|
420
513
|
|
|
514
|
+
function formatCtime(iso) {
|
|
515
|
+
if (!iso) return "";
|
|
516
|
+
const d = new Date(iso);
|
|
517
|
+
const mon = d.toLocaleString(undefined, { month: "short" });
|
|
518
|
+
const day = d.getDate();
|
|
519
|
+
const now = new Date();
|
|
520
|
+
if (d.getFullYear() === now.getFullYear()) return mon + " " + day;
|
|
521
|
+
return mon + " " + day + ", " + d.getFullYear();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function getNewestCtime(node) {
|
|
525
|
+
if (node.__file) return node.__file.createdAt || "";
|
|
526
|
+
let newest = "";
|
|
527
|
+
for (const key of Object.keys(node)) {
|
|
528
|
+
if (key === "__file") continue;
|
|
529
|
+
const t = getNewestCtime(node[key]);
|
|
530
|
+
if (t > newest) newest = t;
|
|
531
|
+
}
|
|
532
|
+
return newest;
|
|
533
|
+
}
|
|
534
|
+
|
|
421
535
|
function countFiles(node) {
|
|
422
536
|
let count = 0;
|
|
423
537
|
for (const key of Object.keys(node)) {
|
|
@@ -427,35 +541,42 @@
|
|
|
427
541
|
return count;
|
|
428
542
|
}
|
|
429
543
|
|
|
430
|
-
function renderTree(node, parentUl, username, depth) {
|
|
544
|
+
function renderTree(node, parentUl, username, depth, pathPrefix = "") {
|
|
431
545
|
const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
|
|
432
546
|
const aDir = Object.keys(node[a]).some((k) => k !== "__file");
|
|
433
547
|
const bDir = Object.keys(node[b]).some((k) => k !== "__file");
|
|
434
548
|
if (aDir !== bDir) return aDir ? -1 : 1;
|
|
549
|
+
const aTime = getNewestCtime(node[a]);
|
|
550
|
+
const bTime = getNewestCtime(node[b]);
|
|
551
|
+
if (aTime !== bTime) return bTime.localeCompare(aTime);
|
|
435
552
|
return a.localeCompare(b);
|
|
436
553
|
});
|
|
437
554
|
for (const key of keys) {
|
|
438
555
|
const child = node[key];
|
|
556
|
+
const nodePath = pathPrefix ? pathPrefix + "/" + key : key;
|
|
439
557
|
const isFile = child.__file && Object.keys(child).length === 1;
|
|
440
558
|
const li = document.createElement("li");
|
|
559
|
+
li.dataset.key = nodePath;
|
|
441
560
|
const row = document.createElement("div");
|
|
442
561
|
row.className = "tree-row";
|
|
443
562
|
row.style.paddingLeft = (depth * 12 + 8) + "px";
|
|
444
563
|
|
|
445
564
|
if (isFile) {
|
|
446
|
-
|
|
447
|
-
row.
|
|
565
|
+
const ctime = child.__file.createdAt || "";
|
|
566
|
+
row.innerHTML = '<span class="name"><span class="arrow"> </span><i class="ph ph-file-text"></i> ' + key + '</span><span class="ctime">' + formatCtime(ctime) + '</span>';
|
|
567
|
+
row.dataset.path = child.__file.path;
|
|
448
568
|
row.dataset.contributor = username;
|
|
449
|
-
row.
|
|
569
|
+
row.dataset.action = "open";
|
|
450
570
|
} else {
|
|
451
571
|
const sub = document.createElement("ul");
|
|
452
572
|
const fileCount = countFiles(child);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
573
|
+
const expanded = getExpandedKeys().has(username + ":" + nodePath);
|
|
574
|
+
const arrow = expanded ? "▼" : "▶";
|
|
575
|
+
if (!expanded) sub.classList.add("collapsed");
|
|
576
|
+
row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span></span>';
|
|
577
|
+
row.dataset.action = "toggle";
|
|
578
|
+
row.dataset.toggleKey = username + ":" + nodePath;
|
|
579
|
+
renderTree(child, sub, username, depth + 1, nodePath);
|
|
459
580
|
li.appendChild(row);
|
|
460
581
|
li.appendChild(sub);
|
|
461
582
|
parentUl.appendChild(li);
|
|
@@ -516,21 +637,71 @@
|
|
|
516
637
|
);
|
|
517
638
|
}
|
|
518
639
|
|
|
640
|
+
filesEl.addEventListener("click", (e) => {
|
|
641
|
+
const row = e.target.closest(".tree-row");
|
|
642
|
+
if (!row) return;
|
|
643
|
+
const action = row.dataset.action;
|
|
644
|
+
if (action === "open") {
|
|
645
|
+
loadContent(row.dataset.contributor, row.dataset.path, row);
|
|
646
|
+
} else if (action === "toggle") {
|
|
647
|
+
const sub = row.nextElementSibling;
|
|
648
|
+
if (!sub) return;
|
|
649
|
+
sub.classList.toggle("collapsed");
|
|
650
|
+
const arrow = row.querySelector(".arrow");
|
|
651
|
+
if (arrow) arrow.innerHTML = sub.classList.contains("collapsed") ? "▶" : "▼";
|
|
652
|
+
const keys = getExpandedKeys();
|
|
653
|
+
const toggleKey = row.dataset.toggleKey;
|
|
654
|
+
if (toggleKey) {
|
|
655
|
+
if (sub.classList.contains("collapsed")) keys.delete(toggleKey);
|
|
656
|
+
else keys.add(toggleKey);
|
|
657
|
+
saveExpandedKeys(keys);
|
|
658
|
+
}
|
|
659
|
+
} else if (action === "contributor") {
|
|
660
|
+
const sub = row.nextElementSibling;
|
|
661
|
+
if (!sub) return;
|
|
662
|
+
const collapsed = sub.classList.contains("collapsed");
|
|
663
|
+
const keys = getExpandedKeys();
|
|
664
|
+
const toggleKey = "contributor:" + row.dataset.contributor;
|
|
665
|
+
if (collapsed) {
|
|
666
|
+
sub.classList.remove("collapsed");
|
|
667
|
+
row.querySelector(".arrow").innerHTML = "▼";
|
|
668
|
+
keys.add(toggleKey);
|
|
669
|
+
} else {
|
|
670
|
+
sub.classList.add("collapsed");
|
|
671
|
+
row.querySelector(".arrow").innerHTML = "▶";
|
|
672
|
+
keys.delete(toggleKey);
|
|
673
|
+
}
|
|
674
|
+
saveExpandedKeys(keys);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
519
678
|
async function loadContributorFiles(username, sub, opts = {}) {
|
|
520
679
|
const silent = !!opts.silent;
|
|
521
|
-
sub.innerHTML = "";
|
|
522
680
|
if (!silent) status("Loading files...");
|
|
523
681
|
const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
|
|
524
682
|
const prefix = username + "/";
|
|
525
|
-
const tree = buildTree(files.map((f) =>
|
|
526
|
-
|
|
683
|
+
const tree = buildTree(files.map((f) => ({
|
|
684
|
+
...f,
|
|
685
|
+
path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path,
|
|
686
|
+
})));
|
|
687
|
+
const tmp = document.createElement("ul");
|
|
688
|
+
renderTree(tree, tmp, username, 1);
|
|
689
|
+
if (sub.hasChildNodes()) {
|
|
690
|
+
morphdom(sub, tmp, {
|
|
691
|
+
childrenOnly: true,
|
|
692
|
+
getNodeKey(node) {
|
|
693
|
+
return node.dataset?.key || "";
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
sub.append(...tmp.childNodes);
|
|
698
|
+
}
|
|
527
699
|
markActiveRow();
|
|
528
700
|
if (!silent) status(files.length + " file(s)");
|
|
529
|
-
// Update contributor row with file count
|
|
530
701
|
const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
|
|
531
702
|
if (row) {
|
|
532
703
|
const arrow = row.querySelector(".arrow").outerHTML;
|
|
533
|
-
row.innerHTML = arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span>';
|
|
704
|
+
row.innerHTML = '<span class="name">' + arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span></span>';
|
|
534
705
|
}
|
|
535
706
|
}
|
|
536
707
|
|
|
@@ -539,6 +710,7 @@
|
|
|
539
710
|
contentEl.innerHTML = "";
|
|
540
711
|
status("Loading contributors...");
|
|
541
712
|
const { contributors } = await (await api("/v1/contributors")).json();
|
|
713
|
+
const expandedKeys = getExpandedKeys();
|
|
542
714
|
|
|
543
715
|
for (const b of contributors) {
|
|
544
716
|
const li = document.createElement("li");
|
|
@@ -547,40 +719,28 @@
|
|
|
547
719
|
row.style.paddingLeft = "8px";
|
|
548
720
|
const sub = document.createElement("ul");
|
|
549
721
|
sub.dataset.contributor = b.username;
|
|
550
|
-
sub.dataset.loaded = "
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
row.querySelector(".arrow").innerHTML = "▼";
|
|
558
|
-
if (!loaded) {
|
|
559
|
-
loaded = true;
|
|
560
|
-
sub.dataset.loaded = "true";
|
|
561
|
-
await loadContributorFiles(b.username, sub);
|
|
562
|
-
}
|
|
563
|
-
} else {
|
|
564
|
-
sub.classList.add("collapsed");
|
|
565
|
-
row.querySelector(".arrow").innerHTML = "▶";
|
|
566
|
-
}
|
|
567
|
-
};
|
|
722
|
+
sub.dataset.loaded = "true";
|
|
723
|
+
const expanded = expandedKeys.has("contributor:" + b.username);
|
|
724
|
+
if (!expanded) sub.classList.add("collapsed");
|
|
725
|
+
const arrow = expanded ? "▼" : "▶";
|
|
726
|
+
row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-user"></i> ' + b.username + '</span>';
|
|
727
|
+
row.dataset.action = "contributor";
|
|
728
|
+
row.dataset.contributor = b.username;
|
|
568
729
|
li.appendChild(row);
|
|
569
730
|
li.appendChild(sub);
|
|
570
731
|
filesEl.appendChild(li);
|
|
732
|
+
}
|
|
571
733
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (match) await loadContent(b.username, savedFile, match);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
734
|
+
await Promise.all(contributors.map((b) => {
|
|
735
|
+
const sub = getLoadedContributorList(b.username);
|
|
736
|
+
return sub ? loadContributorFiles(b.username, sub, { silent: true }) : null;
|
|
737
|
+
}));
|
|
738
|
+
|
|
739
|
+
if (savedFile && savedContributor) {
|
|
740
|
+
const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
|
|
741
|
+
if (match) await loadContent(savedContributor, savedFile, match);
|
|
583
742
|
}
|
|
743
|
+
|
|
584
744
|
status(contributors.length + " contributor(s)");
|
|
585
745
|
}
|
|
586
746
|
|
|
@@ -590,12 +750,10 @@
|
|
|
590
750
|
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
591
751
|
localStorage.setItem("sv-contributor", username);
|
|
592
752
|
localStorage.setItem("sv-file", path);
|
|
753
|
+
closeSidebar();
|
|
593
754
|
status("Loading " + path + "...");
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
headers: { "Content-Type": "application/json" },
|
|
597
|
-
body: JSON.stringify({ cmd: 'cat "' + username + "/" + path + '"' }),
|
|
598
|
-
});
|
|
755
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
756
|
+
const res = await api("/v1/files/" + encodeURIComponent(username) + "/" + encodedPath);
|
|
599
757
|
const text = await res.text();
|
|
600
758
|
contentEl.innerHTML = renderMarkdown(text);
|
|
601
759
|
contentEl.parentElement.scrollTop = 0;
|
|
@@ -639,11 +797,8 @@
|
|
|
639
797
|
scheduleContributorReload(contributor);
|
|
640
798
|
// If this file is currently open, reload its content
|
|
641
799
|
if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
headers: { "Content-Type": "application/json" },
|
|
645
|
-
body: JSON.stringify({ cmd: 'cat "' + contributor + "/" + path + '"' }),
|
|
646
|
-
})
|
|
800
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
801
|
+
api("/v1/files/" + encodeURIComponent(contributor) + "/" + encodedPath)
|
|
647
802
|
.then((res) => res.text())
|
|
648
803
|
.then((text) => { contentEl.innerHTML = renderMarkdown(text); });
|
|
649
804
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/index.ts
|
|
3
|
-
import { join
|
|
3
|
+
import { join } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
import { mkdir
|
|
5
|
+
import { mkdir } from "fs/promises";
|
|
6
6
|
|
|
7
7
|
// src/db.ts
|
|
8
8
|
import { Database } from "bun:sqlite";
|
|
@@ -20,7 +20,7 @@ function initDb(dbPath) {
|
|
|
20
20
|
db.exec(`
|
|
21
21
|
CREATE TABLE IF NOT EXISTS contributors (
|
|
22
22
|
username TEXT PRIMARY KEY,
|
|
23
|
-
|
|
23
|
+
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
24
24
|
created_at TEXT NOT NULL
|
|
25
25
|
);
|
|
26
26
|
|
|
@@ -40,7 +40,42 @@ function initDb(dbPath) {
|
|
|
40
40
|
used_at TEXT,
|
|
41
41
|
used_by TEXT REFERENCES contributors(username)
|
|
42
42
|
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
45
|
+
contributor TEXT NOT NULL,
|
|
46
|
+
path TEXT NOT NULL,
|
|
47
|
+
content TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
modified_at TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY (contributor, path),
|
|
51
|
+
FOREIGN KEY (contributor) REFERENCES contributors(username)
|
|
52
|
+
);
|
|
43
53
|
`);
|
|
54
|
+
const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'").get();
|
|
55
|
+
if (!hasFts) {
|
|
56
|
+
db.exec(`
|
|
57
|
+
CREATE VIRTUAL TABLE items_fts USING fts5(
|
|
58
|
+
path, content, content=items, content_rowid=rowid
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
|
|
62
|
+
INSERT INTO items_fts(rowid, path, content)
|
|
63
|
+
VALUES (new.rowid, new.path, new.content);
|
|
64
|
+
END;
|
|
65
|
+
|
|
66
|
+
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
|
|
67
|
+
INSERT INTO items_fts(items_fts, rowid, path, content)
|
|
68
|
+
VALUES ('delete', old.rowid, old.path, old.content);
|
|
69
|
+
END;
|
|
70
|
+
|
|
71
|
+
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
|
|
72
|
+
INSERT INTO items_fts(items_fts, rowid, path, content)
|
|
73
|
+
VALUES ('delete', old.rowid, old.path, old.content);
|
|
74
|
+
INSERT INTO items_fts(rowid, path, content)
|
|
75
|
+
VALUES (new.rowid, new.path, new.content);
|
|
76
|
+
END;
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
44
79
|
return db;
|
|
45
80
|
}
|
|
46
81
|
function validateUsername(username) {
|
|
@@ -55,20 +90,57 @@ function validateUsername(username) {
|
|
|
55
90
|
}
|
|
56
91
|
return null;
|
|
57
92
|
}
|
|
58
|
-
function
|
|
93
|
+
function validatePath(filePath) {
|
|
94
|
+
if (!filePath || filePath.length === 0) {
|
|
95
|
+
return "Path cannot be empty";
|
|
96
|
+
}
|
|
97
|
+
if (filePath.startsWith("/")) {
|
|
98
|
+
return "Path cannot start with /";
|
|
99
|
+
}
|
|
100
|
+
if (filePath.includes("\\")) {
|
|
101
|
+
return "Path cannot contain backslashes";
|
|
102
|
+
}
|
|
103
|
+
if (filePath.includes("//")) {
|
|
104
|
+
return "Path cannot contain double slashes";
|
|
105
|
+
}
|
|
106
|
+
if (!filePath.endsWith(".md")) {
|
|
107
|
+
return "Path must end in .md";
|
|
108
|
+
}
|
|
109
|
+
const segments = filePath.split("/");
|
|
110
|
+
for (const seg of segments) {
|
|
111
|
+
if (seg === "." || seg === "..") {
|
|
112
|
+
return "Path cannot contain . or .. segments";
|
|
113
|
+
}
|
|
114
|
+
if (seg.length === 0) {
|
|
115
|
+
return "Path cannot contain empty segments";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function createContributor(username, isAdmin) {
|
|
59
121
|
const now = new Date().toISOString();
|
|
60
|
-
getDb().prepare("INSERT INTO contributors (username,
|
|
61
|
-
return { username,
|
|
122
|
+
getDb().prepare("INSERT INTO contributors (username, is_admin, created_at) VALUES (?, ?, ?)").run(username, isAdmin ? 1 : 0, now);
|
|
123
|
+
return { username, is_admin: isAdmin, created_at: now };
|
|
62
124
|
}
|
|
63
125
|
function getContributor(username) {
|
|
64
|
-
const row = getDb().prepare("SELECT username,
|
|
126
|
+
const row = getDb().prepare("SELECT username, is_admin, created_at FROM contributors WHERE username = ?").get(username);
|
|
65
127
|
if (row)
|
|
66
|
-
row.
|
|
128
|
+
row.is_admin = Boolean(row.is_admin);
|
|
67
129
|
return row;
|
|
68
130
|
}
|
|
69
131
|
function listContributors() {
|
|
70
|
-
const rows = getDb().prepare("SELECT username,
|
|
71
|
-
return rows.map((r) => ({ ...r,
|
|
132
|
+
const rows = getDb().prepare("SELECT username, is_admin, created_at FROM contributors ORDER BY created_at ASC").all();
|
|
133
|
+
return rows.map((r) => ({ ...r, is_admin: Boolean(r.is_admin) }));
|
|
134
|
+
}
|
|
135
|
+
function deleteContributor(username) {
|
|
136
|
+
const d = getDb();
|
|
137
|
+
const exists = d.prepare("SELECT 1 FROM contributors WHERE username = ?").get(username);
|
|
138
|
+
if (!exists)
|
|
139
|
+
return false;
|
|
140
|
+
d.prepare("DELETE FROM items WHERE contributor = ?").run(username);
|
|
141
|
+
d.prepare("DELETE FROM api_keys WHERE contributor = ?").run(username);
|
|
142
|
+
d.prepare("DELETE FROM contributors WHERE username = ?").run(username);
|
|
143
|
+
return true;
|
|
72
144
|
}
|
|
73
145
|
function hasAnyContributor() {
|
|
74
146
|
const row = getDb().prepare("SELECT COUNT(*) as count FROM contributors").get();
|
|
@@ -78,7 +150,14 @@ function createApiKey(keyHash, label, contributor) {
|
|
|
78
150
|
const id = `key_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
79
151
|
const now = new Date().toISOString();
|
|
80
152
|
getDb().prepare("INSERT INTO api_keys (id, key_hash, label, contributor, created_at) VALUES (?, ?, ?, ?, ?)").run(id, keyHash, label, contributor, now);
|
|
81
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
key_hash: keyHash,
|
|
156
|
+
label,
|
|
157
|
+
contributor,
|
|
158
|
+
created_at: now,
|
|
159
|
+
last_used_at: null
|
|
160
|
+
};
|
|
82
161
|
}
|
|
83
162
|
function getApiKeyByHash(keyHash) {
|
|
84
163
|
return getDb().prepare("SELECT * FROM api_keys WHERE key_hash = ?").get(keyHash);
|
|
@@ -90,7 +169,13 @@ function createInvite(createdBy) {
|
|
|
90
169
|
const id = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
91
170
|
const now = new Date().toISOString();
|
|
92
171
|
getDb().prepare("INSERT INTO invites (id, created_by, created_at) VALUES (?, ?, ?)").run(id, createdBy, now);
|
|
93
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
created_by: createdBy,
|
|
175
|
+
created_at: now,
|
|
176
|
+
used_at: null,
|
|
177
|
+
used_by: null
|
|
178
|
+
};
|
|
94
179
|
}
|
|
95
180
|
function getInvite(id) {
|
|
96
181
|
return getDb().prepare("SELECT * FROM invites WHERE id = ?").get(id);
|
|
@@ -98,6 +183,84 @@ function getInvite(id) {
|
|
|
98
183
|
function markInviteUsed(id, usedBy) {
|
|
99
184
|
getDb().prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?").run(new Date().toISOString(), usedBy, id);
|
|
100
185
|
}
|
|
186
|
+
function validateOriginCtime(originCtime, originMtime) {
|
|
187
|
+
if (originCtime) {
|
|
188
|
+
const ms = new Date(originCtime).getTime();
|
|
189
|
+
if (ms > 0 && !isNaN(ms))
|
|
190
|
+
return originCtime;
|
|
191
|
+
}
|
|
192
|
+
if (originMtime) {
|
|
193
|
+
const ms = new Date(originMtime).getTime();
|
|
194
|
+
if (ms > 0 && !isNaN(ms))
|
|
195
|
+
return originMtime;
|
|
196
|
+
}
|
|
197
|
+
return new Date().toISOString();
|
|
198
|
+
}
|
|
199
|
+
var MAX_CONTENT_SIZE = 10 * 1024 * 1024;
|
|
200
|
+
function upsertItem(contributor, path, content, originCtime, originMtime) {
|
|
201
|
+
if (Buffer.byteLength(content) > MAX_CONTENT_SIZE) {
|
|
202
|
+
throw new ItemTooLargeError(Buffer.byteLength(content));
|
|
203
|
+
}
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
const createdAt = validateOriginCtime(originCtime, originMtime);
|
|
206
|
+
const modifiedAt = originMtime || now;
|
|
207
|
+
getDb().prepare(`INSERT INTO items (contributor, path, content, created_at, modified_at)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?)
|
|
209
|
+
ON CONFLICT (contributor, path) DO UPDATE SET
|
|
210
|
+
content = excluded.content,
|
|
211
|
+
modified_at = excluded.modified_at`).run(contributor, path, content, createdAt, modifiedAt);
|
|
212
|
+
return getItem(contributor, path);
|
|
213
|
+
}
|
|
214
|
+
function getItem(contributor, path) {
|
|
215
|
+
return getDb().prepare("SELECT contributor, path, content, created_at, modified_at FROM items WHERE contributor = ? AND path = ?").get(contributor, path);
|
|
216
|
+
}
|
|
217
|
+
function listItems(contributor, prefix) {
|
|
218
|
+
let rows;
|
|
219
|
+
if (prefix) {
|
|
220
|
+
rows = getDb().prepare(`SELECT path, length(content) as size, created_at, modified_at
|
|
221
|
+
FROM items WHERE contributor = ? AND path LIKE ?
|
|
222
|
+
ORDER BY modified_at DESC`).all(contributor, prefix + "%");
|
|
223
|
+
} else {
|
|
224
|
+
rows = getDb().prepare(`SELECT path, length(content) as size, created_at, modified_at
|
|
225
|
+
FROM items WHERE contributor = ?
|
|
226
|
+
ORDER BY modified_at DESC`).all(contributor);
|
|
227
|
+
}
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
function deleteItem(contributor, path) {
|
|
231
|
+
const result = getDb().prepare("DELETE FROM items WHERE contributor = ? AND path = ?").run(contributor, path);
|
|
232
|
+
return result.changes > 0;
|
|
233
|
+
}
|
|
234
|
+
function searchItems(query, contributor, limit = 10) {
|
|
235
|
+
if (contributor) {
|
|
236
|
+
return getDb().prepare(`SELECT i.contributor, i.path,
|
|
237
|
+
snippet(items_fts, 1, '<b>', '</b>', '...', 32) as snippet,
|
|
238
|
+
rank
|
|
239
|
+
FROM items_fts
|
|
240
|
+
JOIN items i ON items_fts.rowid = i.rowid
|
|
241
|
+
WHERE items_fts MATCH ?
|
|
242
|
+
AND i.contributor = ?
|
|
243
|
+
ORDER BY rank
|
|
244
|
+
LIMIT ?`).all(query, contributor, limit);
|
|
245
|
+
}
|
|
246
|
+
return getDb().prepare(`SELECT i.contributor, i.path,
|
|
247
|
+
snippet(items_fts, 1, '<b>', '</b>', '...', 32) as snippet,
|
|
248
|
+
rank
|
|
249
|
+
FROM items_fts
|
|
250
|
+
JOIN items i ON items_fts.rowid = i.rowid
|
|
251
|
+
WHERE items_fts MATCH ?
|
|
252
|
+
ORDER BY rank
|
|
253
|
+
LIMIT ?`).all(query, limit);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
class ItemTooLargeError extends Error {
|
|
257
|
+
size;
|
|
258
|
+
constructor(size) {
|
|
259
|
+
super(`Content too large: ${size} bytes (max ${MAX_CONTENT_SIZE})`);
|
|
260
|
+
this.name = "ItemTooLargeError";
|
|
261
|
+
this.size = size;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
101
264
|
|
|
102
265
|
// ../node_modules/.bun/hono@4.11.9/node_modules/hono/dist/compose.js
|
|
103
266
|
var compose = (middleware, onError, onNotFound) => {
|
|
@@ -1661,140 +1824,6 @@ function getAuthCtx(c) {
|
|
|
1661
1824
|
return c.get("authCtx");
|
|
1662
1825
|
}
|
|
1663
1826
|
|
|
1664
|
-
// src/storage.ts
|
|
1665
|
-
import { join, dirname, relative, sep } from "path";
|
|
1666
|
-
import { mkdir, writeFile, unlink, readdir, stat, readFile, rename, rmdir } from "fs/promises";
|
|
1667
|
-
import { existsSync } from "fs";
|
|
1668
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
1669
|
-
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
1670
|
-
function validatePath(filePath) {
|
|
1671
|
-
if (!filePath || filePath.length === 0) {
|
|
1672
|
-
return "Path cannot be empty";
|
|
1673
|
-
}
|
|
1674
|
-
if (filePath.startsWith("/")) {
|
|
1675
|
-
return "Path cannot start with /";
|
|
1676
|
-
}
|
|
1677
|
-
if (filePath.includes("\\")) {
|
|
1678
|
-
return "Path cannot contain backslashes";
|
|
1679
|
-
}
|
|
1680
|
-
if (filePath.includes("//")) {
|
|
1681
|
-
return "Path cannot contain double slashes";
|
|
1682
|
-
}
|
|
1683
|
-
if (!filePath.endsWith(".md")) {
|
|
1684
|
-
return "Path must end in .md";
|
|
1685
|
-
}
|
|
1686
|
-
const segments = filePath.split("/");
|
|
1687
|
-
for (const seg of segments) {
|
|
1688
|
-
if (seg === "." || seg === "..") {
|
|
1689
|
-
return "Path cannot contain . or .. segments";
|
|
1690
|
-
}
|
|
1691
|
-
if (seg.length === 0) {
|
|
1692
|
-
return "Path cannot contain empty segments";
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
return null;
|
|
1696
|
-
}
|
|
1697
|
-
function resolvePath(storageRoot, contributor, filePath) {
|
|
1698
|
-
return join(storageRoot, contributor, filePath);
|
|
1699
|
-
}
|
|
1700
|
-
async function ensureContributorDir(storageRoot, contributor) {
|
|
1701
|
-
const dir = join(storageRoot, contributor);
|
|
1702
|
-
await mkdir(dir, { recursive: true });
|
|
1703
|
-
}
|
|
1704
|
-
async function writeFileAtomic(storageRoot, contributor, filePath, content) {
|
|
1705
|
-
const contentBuf = typeof content === "string" ? Buffer.from(content) : content;
|
|
1706
|
-
if (contentBuf.length > MAX_FILE_SIZE) {
|
|
1707
|
-
throw new FileTooLargeError(contentBuf.length);
|
|
1708
|
-
}
|
|
1709
|
-
const absPath = resolvePath(storageRoot, contributor, filePath);
|
|
1710
|
-
const dir = dirname(absPath);
|
|
1711
|
-
await mkdir(dir, { recursive: true });
|
|
1712
|
-
const tmpPath = `${absPath}.tmp.${randomUUID2().slice(0, 8)}`;
|
|
1713
|
-
try {
|
|
1714
|
-
await writeFile(tmpPath, contentBuf);
|
|
1715
|
-
await rename(tmpPath, absPath);
|
|
1716
|
-
} catch (e) {
|
|
1717
|
-
try {
|
|
1718
|
-
await unlink(tmpPath);
|
|
1719
|
-
} catch {}
|
|
1720
|
-
throw e;
|
|
1721
|
-
}
|
|
1722
|
-
const fileStat = await stat(absPath);
|
|
1723
|
-
return {
|
|
1724
|
-
path: filePath,
|
|
1725
|
-
size: fileStat.size,
|
|
1726
|
-
modifiedAt: fileStat.mtime.toISOString()
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
async function deleteFile(storageRoot, contributor, filePath) {
|
|
1730
|
-
const absPath = resolvePath(storageRoot, contributor, filePath);
|
|
1731
|
-
if (!existsSync(absPath)) {
|
|
1732
|
-
throw new FileNotFoundError(filePath);
|
|
1733
|
-
}
|
|
1734
|
-
await unlink(absPath);
|
|
1735
|
-
const contributorRoot = join(storageRoot, contributor);
|
|
1736
|
-
let dir = dirname(absPath);
|
|
1737
|
-
while (dir !== contributorRoot && dir.startsWith(contributorRoot)) {
|
|
1738
|
-
try {
|
|
1739
|
-
await rmdir(dir);
|
|
1740
|
-
dir = dirname(dir);
|
|
1741
|
-
} catch {
|
|
1742
|
-
break;
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
async function listFiles(storageRoot, contributor, prefix) {
|
|
1747
|
-
const contributorRoot = join(storageRoot, contributor);
|
|
1748
|
-
if (!existsSync(contributorRoot)) {
|
|
1749
|
-
return [];
|
|
1750
|
-
}
|
|
1751
|
-
const files = [];
|
|
1752
|
-
await walkDir(contributorRoot, contributorRoot, prefix, files);
|
|
1753
|
-
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
1754
|
-
return files;
|
|
1755
|
-
}
|
|
1756
|
-
async function walkDir(dir, contributorRoot, prefix, results) {
|
|
1757
|
-
let entries;
|
|
1758
|
-
try {
|
|
1759
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
1760
|
-
} catch {
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
|
-
for (const entry of entries) {
|
|
1764
|
-
const fullPath = join(dir, entry.name);
|
|
1765
|
-
if (entry.isDirectory()) {
|
|
1766
|
-
await walkDir(fullPath, contributorRoot, prefix, results);
|
|
1767
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
1768
|
-
const relPath = relative(contributorRoot, fullPath).split(sep).join("/");
|
|
1769
|
-
if (prefix && !relPath.startsWith(prefix)) {
|
|
1770
|
-
continue;
|
|
1771
|
-
}
|
|
1772
|
-
const fileStat = await stat(fullPath);
|
|
1773
|
-
results.push({
|
|
1774
|
-
path: relPath,
|
|
1775
|
-
size: fileStat.size,
|
|
1776
|
-
modifiedAt: fileStat.mtime.toISOString()
|
|
1777
|
-
});
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
class FileNotFoundError extends Error {
|
|
1783
|
-
constructor(path) {
|
|
1784
|
-
super(`File not found: ${path}`);
|
|
1785
|
-
this.name = "FileNotFoundError";
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
class FileTooLargeError extends Error {
|
|
1790
|
-
size;
|
|
1791
|
-
constructor(size) {
|
|
1792
|
-
super(`File too large: ${size} bytes (max ${MAX_FILE_SIZE})`);
|
|
1793
|
-
this.name = "FileTooLargeError";
|
|
1794
|
-
this.size = size;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
1827
|
// src/sse.ts
|
|
1799
1828
|
var clients = new Set;
|
|
1800
1829
|
function addClient(controller) {
|
|
@@ -1818,178 +1847,6 @@ data: ${JSON.stringify(data)}
|
|
|
1818
1847
|
}
|
|
1819
1848
|
}
|
|
1820
1849
|
|
|
1821
|
-
// src/qmd.ts
|
|
1822
|
-
async function isQmdAvailable() {
|
|
1823
|
-
try {
|
|
1824
|
-
const proc = Bun.spawn(["qmd", "status"], { stdout: "pipe", stderr: "pipe" });
|
|
1825
|
-
await proc.exited;
|
|
1826
|
-
return true;
|
|
1827
|
-
} catch {
|
|
1828
|
-
return false;
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
async function addCollection(storageRoot, contributor) {
|
|
1832
|
-
const dir = `${storageRoot}/${contributor.username}`;
|
|
1833
|
-
const proc = Bun.spawn(["qmd", "collection", "add", dir, "--name", contributor.username, "--mask", "**/*.md"], { stdout: "pipe", stderr: "pipe" });
|
|
1834
|
-
const exitCode = await proc.exited;
|
|
1835
|
-
if (exitCode !== 0) {
|
|
1836
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1837
|
-
if (!stderr.includes("already exists")) {
|
|
1838
|
-
console.error(`QMD collection add failed for ${contributor.username}:`, stderr);
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
var updateInFlight = false;
|
|
1843
|
-
var updateQueued = false;
|
|
1844
|
-
function triggerUpdate() {
|
|
1845
|
-
if (updateInFlight) {
|
|
1846
|
-
updateQueued = true;
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
runUpdate();
|
|
1850
|
-
}
|
|
1851
|
-
async function runUpdate() {
|
|
1852
|
-
updateInFlight = true;
|
|
1853
|
-
try {
|
|
1854
|
-
const proc = Bun.spawn(["qmd", "update"], { stdout: "pipe", stderr: "pipe" });
|
|
1855
|
-
const exitCode = await proc.exited;
|
|
1856
|
-
if (exitCode !== 0) {
|
|
1857
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1858
|
-
console.error("QMD update failed:", stderr);
|
|
1859
|
-
}
|
|
1860
|
-
} catch (e) {
|
|
1861
|
-
console.error("QMD update error:", e);
|
|
1862
|
-
} finally {
|
|
1863
|
-
updateInFlight = false;
|
|
1864
|
-
if (updateQueued) {
|
|
1865
|
-
updateQueued = false;
|
|
1866
|
-
runUpdate();
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
async function search(query, options = {}) {
|
|
1871
|
-
const limit = options.limit ?? 10;
|
|
1872
|
-
const args = ["search", query, "--json", "-n", String(limit)];
|
|
1873
|
-
if (options.collection) {
|
|
1874
|
-
args.push("-c", options.collection);
|
|
1875
|
-
}
|
|
1876
|
-
try {
|
|
1877
|
-
const proc = Bun.spawn(["qmd", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
1878
|
-
const stdout = await new Response(proc.stdout).text();
|
|
1879
|
-
const exitCode = await proc.exited;
|
|
1880
|
-
if (exitCode !== 0) {
|
|
1881
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1882
|
-
console.error("QMD search failed:", stderr);
|
|
1883
|
-
return [];
|
|
1884
|
-
}
|
|
1885
|
-
if (!stdout.trim())
|
|
1886
|
-
return [];
|
|
1887
|
-
return JSON.parse(stdout);
|
|
1888
|
-
} catch (e) {
|
|
1889
|
-
console.error("QMD search error:", e);
|
|
1890
|
-
return [];
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
async function syncContributors(storageRoot, contributors) {
|
|
1894
|
-
for (const contributor of contributors) {
|
|
1895
|
-
await addCollection(storageRoot, contributor);
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
// src/shell.ts
|
|
1900
|
-
var ALLOWED_COMMANDS = new Set([
|
|
1901
|
-
"ls",
|
|
1902
|
-
"cat",
|
|
1903
|
-
"head",
|
|
1904
|
-
"tail",
|
|
1905
|
-
"find",
|
|
1906
|
-
"grep",
|
|
1907
|
-
"wc",
|
|
1908
|
-
"tree",
|
|
1909
|
-
"stat"
|
|
1910
|
-
]);
|
|
1911
|
-
var MAX_STDOUT = 1024 * 1024;
|
|
1912
|
-
var TIMEOUT_MS = 1e4;
|
|
1913
|
-
function parseCommand(cmd) {
|
|
1914
|
-
const args = [];
|
|
1915
|
-
let current = "";
|
|
1916
|
-
let inSingle = false;
|
|
1917
|
-
let inDouble = false;
|
|
1918
|
-
for (let i = 0;i < cmd.length; i++) {
|
|
1919
|
-
const ch = cmd[i];
|
|
1920
|
-
if (ch === "'" && !inDouble) {
|
|
1921
|
-
inSingle = !inSingle;
|
|
1922
|
-
} else if (ch === '"' && !inSingle) {
|
|
1923
|
-
inDouble = !inDouble;
|
|
1924
|
-
} else if (ch === " " && !inSingle && !inDouble) {
|
|
1925
|
-
if (current.length > 0) {
|
|
1926
|
-
args.push(current);
|
|
1927
|
-
current = "";
|
|
1928
|
-
}
|
|
1929
|
-
} else {
|
|
1930
|
-
current += ch;
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
if (current.length > 0) {
|
|
1934
|
-
args.push(current);
|
|
1935
|
-
}
|
|
1936
|
-
return args;
|
|
1937
|
-
}
|
|
1938
|
-
async function executeCommand(cmd, storageRoot) {
|
|
1939
|
-
const argv = parseCommand(cmd.trim());
|
|
1940
|
-
if (argv.length === 0) {
|
|
1941
|
-
throw new ShellValidationError("Empty command");
|
|
1942
|
-
}
|
|
1943
|
-
const command = argv[0];
|
|
1944
|
-
if (!ALLOWED_COMMANDS.has(command)) {
|
|
1945
|
-
throw new ShellValidationError(`Command not allowed: ${command}. Allowed: ${[...ALLOWED_COMMANDS].join(", ")}`);
|
|
1946
|
-
}
|
|
1947
|
-
for (const arg of argv.slice(1)) {
|
|
1948
|
-
if (arg.includes("..")) {
|
|
1949
|
-
throw new ShellValidationError("Path traversal (..) is not allowed");
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
const proc = Bun.spawn(argv, {
|
|
1953
|
-
cwd: storageRoot,
|
|
1954
|
-
stdout: "pipe",
|
|
1955
|
-
stderr: "pipe",
|
|
1956
|
-
env: {}
|
|
1957
|
-
});
|
|
1958
|
-
const timeout = setTimeout(() => {
|
|
1959
|
-
proc.kill();
|
|
1960
|
-
}, TIMEOUT_MS);
|
|
1961
|
-
try {
|
|
1962
|
-
const [stdoutBuf, stderrBuf] = await Promise.all([
|
|
1963
|
-
new Response(proc.stdout).arrayBuffer(),
|
|
1964
|
-
new Response(proc.stderr).arrayBuffer()
|
|
1965
|
-
]);
|
|
1966
|
-
await proc.exited;
|
|
1967
|
-
const truncated = stdoutBuf.byteLength > MAX_STDOUT;
|
|
1968
|
-
const stdoutBytes = truncated ? stdoutBuf.slice(0, MAX_STDOUT) : stdoutBuf;
|
|
1969
|
-
let stdout = new TextDecoder().decode(stdoutBytes);
|
|
1970
|
-
if (truncated) {
|
|
1971
|
-
stdout += `
|
|
1972
|
-
[truncated]`;
|
|
1973
|
-
}
|
|
1974
|
-
const stderr = new TextDecoder().decode(stderrBuf);
|
|
1975
|
-
return {
|
|
1976
|
-
stdout,
|
|
1977
|
-
stderr,
|
|
1978
|
-
exitCode: proc.exitCode ?? 1,
|
|
1979
|
-
truncated
|
|
1980
|
-
};
|
|
1981
|
-
} finally {
|
|
1982
|
-
clearTimeout(timeout);
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
class ShellValidationError extends Error {
|
|
1987
|
-
constructor(message) {
|
|
1988
|
-
super(message);
|
|
1989
|
-
this.name = "ShellValidationError";
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
1850
|
// src/routes.ts
|
|
1994
1851
|
var uiPath = resolve(import.meta.dirname, "index.html");
|
|
1995
1852
|
var isDev = true;
|
|
@@ -2010,7 +1867,7 @@ function extractFileInfo(reqPath) {
|
|
|
2010
1867
|
filePath: decoded.slice(slashIdx + 1)
|
|
2011
1868
|
};
|
|
2012
1869
|
}
|
|
2013
|
-
function createApp(
|
|
1870
|
+
function createApp() {
|
|
2014
1871
|
const app = new Hono2;
|
|
2015
1872
|
app.get("/", (c) => {
|
|
2016
1873
|
return c.html(isDev ? readFileSync(uiPath, "utf-8") : uiHtmlCached);
|
|
@@ -2045,8 +1902,6 @@ function createApp(storageRoot) {
|
|
|
2045
1902
|
return c.json({ error: "A contributor with that username already exists" }, 409);
|
|
2046
1903
|
}
|
|
2047
1904
|
const contributor = createContributor(username, isFirstUser);
|
|
2048
|
-
await ensureContributorDir(storageRoot, contributor.username);
|
|
2049
|
-
addCollection(storageRoot, contributor).catch((e) => console.error("Failed to register QMD collection:", e));
|
|
2050
1905
|
const rawToken = generateToken();
|
|
2051
1906
|
createApiKey(hashToken(rawToken), `${username}-default`, contributor.username);
|
|
2052
1907
|
if (!isFirstUser && body.invite) {
|
|
@@ -2071,8 +1926,8 @@ function createApp(storageRoot) {
|
|
|
2071
1926
|
});
|
|
2072
1927
|
authed.post("/v1/invites", (c) => {
|
|
2073
1928
|
const { contributor } = getAuthCtx(c);
|
|
2074
|
-
if (!contributor.
|
|
2075
|
-
return c.json({ error: "Only the
|
|
1929
|
+
if (!contributor.is_admin) {
|
|
1930
|
+
return c.json({ error: "Only the admin can generate invite codes" }, 403);
|
|
2076
1931
|
}
|
|
2077
1932
|
const invite = createInvite(contributor.username);
|
|
2078
1933
|
return c.json({
|
|
@@ -2089,27 +1944,20 @@ function createApp(storageRoot) {
|
|
|
2089
1944
|
}))
|
|
2090
1945
|
});
|
|
2091
1946
|
});
|
|
2092
|
-
authed.
|
|
2093
|
-
const
|
|
2094
|
-
|
|
2095
|
-
|
|
1947
|
+
authed.delete("/v1/contributors/:username", (c) => {
|
|
1948
|
+
const { contributor } = getAuthCtx(c);
|
|
1949
|
+
const target = c.req.param("username");
|
|
1950
|
+
if (!contributor.is_admin) {
|
|
1951
|
+
return c.json({ error: "Only the admin can delete contributors" }, 403);
|
|
2096
1952
|
}
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
"X-Exit-Code": String(result.exitCode),
|
|
2104
|
-
"X-Stderr": encodeURIComponent(result.stderr)
|
|
2105
|
-
}
|
|
2106
|
-
});
|
|
2107
|
-
} catch (e) {
|
|
2108
|
-
if (e instanceof ShellValidationError) {
|
|
2109
|
-
return c.json({ error: e.message }, 400);
|
|
2110
|
-
}
|
|
2111
|
-
throw e;
|
|
1953
|
+
if (target === contributor.username) {
|
|
1954
|
+
return c.json({ error: "Cannot delete yourself" }, 400);
|
|
1955
|
+
}
|
|
1956
|
+
const found = deleteContributor(target);
|
|
1957
|
+
if (!found) {
|
|
1958
|
+
return c.json({ error: "Contributor not found" }, 404);
|
|
2112
1959
|
}
|
|
1960
|
+
return c.body(null, 204);
|
|
2113
1961
|
});
|
|
2114
1962
|
authed.put("/v1/files/*", async (c) => {
|
|
2115
1963
|
const { contributor } = getAuthCtx(c);
|
|
@@ -2128,24 +1976,31 @@ function createApp(storageRoot) {
|
|
|
2128
1976
|
return c.json({ error: pathError }, 400);
|
|
2129
1977
|
}
|
|
2130
1978
|
const content = await c.req.text();
|
|
1979
|
+
const now = new Date().toISOString();
|
|
1980
|
+
const originCtime = c.req.header("X-Origin-Ctime") || now;
|
|
1981
|
+
const originMtime = c.req.header("X-Origin-Mtime") || now;
|
|
2131
1982
|
try {
|
|
2132
|
-
const
|
|
1983
|
+
const item = upsertItem(parsed.username, parsed.filePath, content, originCtime, originMtime);
|
|
2133
1984
|
broadcast("file_updated", {
|
|
2134
1985
|
contributor: parsed.username,
|
|
2135
|
-
path:
|
|
2136
|
-
size:
|
|
2137
|
-
modifiedAt:
|
|
1986
|
+
path: item.path,
|
|
1987
|
+
size: Buffer.byteLength(item.content),
|
|
1988
|
+
modifiedAt: item.modified_at
|
|
1989
|
+
});
|
|
1990
|
+
return c.json({
|
|
1991
|
+
path: item.path,
|
|
1992
|
+
size: Buffer.byteLength(item.content),
|
|
1993
|
+
createdAt: item.created_at,
|
|
1994
|
+
modifiedAt: item.modified_at
|
|
2138
1995
|
});
|
|
2139
|
-
triggerUpdate();
|
|
2140
|
-
return c.json(result);
|
|
2141
1996
|
} catch (e) {
|
|
2142
|
-
if (e instanceof
|
|
1997
|
+
if (e instanceof ItemTooLargeError) {
|
|
2143
1998
|
return c.json({ error: e.message }, 413);
|
|
2144
1999
|
}
|
|
2145
2000
|
throw e;
|
|
2146
2001
|
}
|
|
2147
2002
|
});
|
|
2148
|
-
authed.delete("/v1/files/*",
|
|
2003
|
+
authed.delete("/v1/files/*", (c) => {
|
|
2149
2004
|
const { contributor } = getAuthCtx(c);
|
|
2150
2005
|
const parsed = extractFileInfo(c.req.path);
|
|
2151
2006
|
if (!parsed) {
|
|
@@ -2158,22 +2013,17 @@ function createApp(storageRoot) {
|
|
|
2158
2013
|
if (pathError) {
|
|
2159
2014
|
return c.json({ error: pathError }, 400);
|
|
2160
2015
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
contributor: parsed.username,
|
|
2165
|
-
path: parsed.filePath
|
|
2166
|
-
});
|
|
2167
|
-
triggerUpdate();
|
|
2168
|
-
return c.body(null, 204);
|
|
2169
|
-
} catch (e) {
|
|
2170
|
-
if (e instanceof FileNotFoundError) {
|
|
2171
|
-
return c.json({ error: "File not found" }, 404);
|
|
2172
|
-
}
|
|
2173
|
-
throw e;
|
|
2016
|
+
const found = deleteItem(parsed.username, parsed.filePath);
|
|
2017
|
+
if (!found) {
|
|
2018
|
+
return c.json({ error: "File not found" }, 404);
|
|
2174
2019
|
}
|
|
2020
|
+
broadcast("file_deleted", {
|
|
2021
|
+
contributor: parsed.username,
|
|
2022
|
+
path: parsed.filePath
|
|
2023
|
+
});
|
|
2024
|
+
return c.body(null, 204);
|
|
2175
2025
|
});
|
|
2176
|
-
authed.get("/v1/files",
|
|
2026
|
+
authed.get("/v1/files", (c) => {
|
|
2177
2027
|
const prefix = c.req.query("prefix") || "";
|
|
2178
2028
|
if (!prefix) {
|
|
2179
2029
|
return c.json({ error: "prefix query parameter is required" }, 400);
|
|
@@ -2184,14 +2034,35 @@ function createApp(storageRoot) {
|
|
|
2184
2034
|
if (!getContributor(username)) {
|
|
2185
2035
|
return c.json({ error: "Contributor not found" }, 404);
|
|
2186
2036
|
}
|
|
2187
|
-
const
|
|
2037
|
+
const items = listItems(username, subPrefix);
|
|
2188
2038
|
return c.json({
|
|
2189
|
-
files:
|
|
2190
|
-
|
|
2191
|
-
|
|
2039
|
+
files: items.map((f) => ({
|
|
2040
|
+
path: `${username}/${f.path}`,
|
|
2041
|
+
size: f.size,
|
|
2042
|
+
createdAt: f.created_at,
|
|
2043
|
+
modifiedAt: f.modified_at
|
|
2192
2044
|
}))
|
|
2193
2045
|
});
|
|
2194
2046
|
});
|
|
2047
|
+
authed.get("/v1/files/*", (c) => {
|
|
2048
|
+
const parsed = extractFileInfo(c.req.path);
|
|
2049
|
+
if (!parsed) {
|
|
2050
|
+
return c.json({ error: "Invalid file path" }, 400);
|
|
2051
|
+
}
|
|
2052
|
+
const item = getItem(parsed.username, parsed.filePath);
|
|
2053
|
+
if (!item) {
|
|
2054
|
+
return c.json({ error: "File not found" }, 404);
|
|
2055
|
+
}
|
|
2056
|
+
return new Response(item.content, {
|
|
2057
|
+
status: 200,
|
|
2058
|
+
headers: {
|
|
2059
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
2060
|
+
"X-Created-At": item.created_at,
|
|
2061
|
+
"X-Modified-At": item.modified_at,
|
|
2062
|
+
"X-Size": String(Buffer.byteLength(item.content))
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
});
|
|
2195
2066
|
authed.get("/v1/events", (c) => {
|
|
2196
2067
|
let ctrl;
|
|
2197
2068
|
let heartbeat;
|
|
@@ -2227,22 +2098,17 @@ data: {}
|
|
|
2227
2098
|
}
|
|
2228
2099
|
});
|
|
2229
2100
|
});
|
|
2230
|
-
authed.get("/v1/search",
|
|
2101
|
+
authed.get("/v1/search", (c) => {
|
|
2231
2102
|
const q = c.req.query("q");
|
|
2232
2103
|
if (!q) {
|
|
2233
2104
|
return c.json({ error: "q parameter is required" }, 400);
|
|
2234
2105
|
}
|
|
2235
2106
|
const contributorParam = c.req.query("contributor") || undefined;
|
|
2236
2107
|
const limit = parseInt(c.req.query("limit") || "10", 10);
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
const contributor = getContributor(contributorParam);
|
|
2240
|
-
if (!contributor) {
|
|
2241
|
-
return c.json({ error: "Contributor not found" }, 404);
|
|
2242
|
-
}
|
|
2243
|
-
collectionName = contributor.username;
|
|
2108
|
+
if (contributorParam && !getContributor(contributorParam)) {
|
|
2109
|
+
return c.json({ error: "Contributor not found" }, 404);
|
|
2244
2110
|
}
|
|
2245
|
-
const results =
|
|
2111
|
+
const results = searchItems(q, contributorParam, limit);
|
|
2246
2112
|
return c.json({ results });
|
|
2247
2113
|
});
|
|
2248
2114
|
app.route("/", authed);
|
|
@@ -2251,28 +2117,14 @@ data: {}
|
|
|
2251
2117
|
|
|
2252
2118
|
// src/index.ts
|
|
2253
2119
|
var PORT = parseInt(process.env.PORT || "3000", 10);
|
|
2254
|
-
var DATA_DIR = process.env.DATA_DIR ||
|
|
2255
|
-
var dbPath =
|
|
2256
|
-
|
|
2257
|
-
await mkdir2(DATA_DIR, { recursive: true });
|
|
2258
|
-
await mkdir2(storageRoot, { recursive: true });
|
|
2120
|
+
var DATA_DIR = process.env.DATA_DIR || join(homedir(), ".seedvault", "data");
|
|
2121
|
+
var dbPath = join(DATA_DIR, "seedvault.db");
|
|
2122
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
2259
2123
|
initDb(dbPath);
|
|
2260
|
-
var app = createApp(
|
|
2124
|
+
var app = createApp();
|
|
2261
2125
|
console.log(`Seedvault server starting on port ${PORT}`);
|
|
2262
2126
|
console.log(` Data dir: ${DATA_DIR}`);
|
|
2263
2127
|
console.log(` Database: ${dbPath}`);
|
|
2264
|
-
console.log(` Storage: ${storageRoot}`);
|
|
2265
|
-
var qmdAvailable = await isQmdAvailable();
|
|
2266
|
-
if (qmdAvailable) {
|
|
2267
|
-
console.log(" QMD: available");
|
|
2268
|
-
const contributors = listContributors();
|
|
2269
|
-
if (contributors.length > 0) {
|
|
2270
|
-
await syncContributors(storageRoot, contributors);
|
|
2271
|
-
console.log(` QMD: synced ${contributors.length} collection(s)`);
|
|
2272
|
-
}
|
|
2273
|
-
} else {
|
|
2274
|
-
console.log(" QMD: not found (search disabled)");
|
|
2275
|
-
}
|
|
2276
2128
|
var server = Bun.serve({
|
|
2277
2129
|
port: PORT,
|
|
2278
2130
|
fetch: app.fetch
|