@seedvault/server 0.1.4 → 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 +194 -56
- package/dist/server.js +227 -433
- 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,6 +488,15 @@
|
|
|
404
488
|
const savedContributor = localStorage.getItem("sv-contributor") || "";
|
|
405
489
|
const savedFile = localStorage.getItem("sv-file") || "";
|
|
406
490
|
|
|
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
|
+
|
|
407
500
|
function buildTree(fileEntries) {
|
|
408
501
|
const root = {};
|
|
409
502
|
for (const f of fileEntries) {
|
|
@@ -418,8 +511,18 @@
|
|
|
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
|
+
|
|
421
524
|
function getNewestCtime(node) {
|
|
422
|
-
if (node.__file) return node.__file.
|
|
525
|
+
if (node.__file) return node.__file.createdAt || "";
|
|
423
526
|
let newest = "";
|
|
424
527
|
for (const key of Object.keys(node)) {
|
|
425
528
|
if (key === "__file") continue;
|
|
@@ -438,7 +541,7 @@
|
|
|
438
541
|
return count;
|
|
439
542
|
}
|
|
440
543
|
|
|
441
|
-
function renderTree(node, parentUl, username, depth) {
|
|
544
|
+
function renderTree(node, parentUl, username, depth, pathPrefix = "") {
|
|
442
545
|
const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
|
|
443
546
|
const aDir = Object.keys(node[a]).some((k) => k !== "__file");
|
|
444
547
|
const bDir = Object.keys(node[b]).some((k) => k !== "__file");
|
|
@@ -450,26 +553,30 @@
|
|
|
450
553
|
});
|
|
451
554
|
for (const key of keys) {
|
|
452
555
|
const child = node[key];
|
|
556
|
+
const nodePath = pathPrefix ? pathPrefix + "/" + key : key;
|
|
453
557
|
const isFile = child.__file && Object.keys(child).length === 1;
|
|
454
558
|
const li = document.createElement("li");
|
|
559
|
+
li.dataset.key = nodePath;
|
|
455
560
|
const row = document.createElement("div");
|
|
456
561
|
row.className = "tree-row";
|
|
457
562
|
row.style.paddingLeft = (depth * 12 + 8) + "px";
|
|
458
563
|
|
|
459
564
|
if (isFile) {
|
|
460
|
-
|
|
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>';
|
|
461
567
|
row.dataset.path = child.__file.path;
|
|
462
568
|
row.dataset.contributor = username;
|
|
463
|
-
row.
|
|
569
|
+
row.dataset.action = "open";
|
|
464
570
|
} else {
|
|
465
571
|
const sub = document.createElement("ul");
|
|
466
572
|
const fileCount = countFiles(child);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
473
580
|
li.appendChild(row);
|
|
474
581
|
li.appendChild(sub);
|
|
475
582
|
parentUl.appendChild(li);
|
|
@@ -530,9 +637,46 @@
|
|
|
530
637
|
);
|
|
531
638
|
}
|
|
532
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
|
+
|
|
533
678
|
async function loadContributorFiles(username, sub, opts = {}) {
|
|
534
679
|
const silent = !!opts.silent;
|
|
535
|
-
sub.innerHTML = "";
|
|
536
680
|
if (!silent) status("Loading files...");
|
|
537
681
|
const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
|
|
538
682
|
const prefix = username + "/";
|
|
@@ -540,14 +684,24 @@
|
|
|
540
684
|
...f,
|
|
541
685
|
path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path,
|
|
542
686
|
})));
|
|
543
|
-
|
|
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
|
+
}
|
|
544
699
|
markActiveRow();
|
|
545
700
|
if (!silent) status(files.length + " file(s)");
|
|
546
|
-
// Update contributor row with file count
|
|
547
701
|
const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
|
|
548
702
|
if (row) {
|
|
549
703
|
const arrow = row.querySelector(".arrow").outerHTML;
|
|
550
|
-
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>';
|
|
551
705
|
}
|
|
552
706
|
}
|
|
553
707
|
|
|
@@ -556,6 +710,7 @@
|
|
|
556
710
|
contentEl.innerHTML = "";
|
|
557
711
|
status("Loading contributors...");
|
|
558
712
|
const { contributors } = await (await api("/v1/contributors")).json();
|
|
713
|
+
const expandedKeys = getExpandedKeys();
|
|
559
714
|
|
|
560
715
|
for (const b of contributors) {
|
|
561
716
|
const li = document.createElement("li");
|
|
@@ -564,40 +719,28 @@
|
|
|
564
719
|
row.style.paddingLeft = "8px";
|
|
565
720
|
const sub = document.createElement("ul");
|
|
566
721
|
sub.dataset.contributor = b.username;
|
|
567
|
-
sub.dataset.loaded = "
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
row.querySelector(".arrow").innerHTML = "▼";
|
|
575
|
-
if (!loaded) {
|
|
576
|
-
loaded = true;
|
|
577
|
-
sub.dataset.loaded = "true";
|
|
578
|
-
await loadContributorFiles(b.username, sub);
|
|
579
|
-
}
|
|
580
|
-
} else {
|
|
581
|
-
sub.classList.add("collapsed");
|
|
582
|
-
row.querySelector(".arrow").innerHTML = "▶";
|
|
583
|
-
}
|
|
584
|
-
};
|
|
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;
|
|
585
729
|
li.appendChild(row);
|
|
586
730
|
li.appendChild(sub);
|
|
587
731
|
filesEl.appendChild(li);
|
|
732
|
+
}
|
|
588
733
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (match) await loadContent(b.username, savedFile, match);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
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);
|
|
600
742
|
}
|
|
743
|
+
|
|
601
744
|
status(contributors.length + " contributor(s)");
|
|
602
745
|
}
|
|
603
746
|
|
|
@@ -607,12 +750,10 @@
|
|
|
607
750
|
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
608
751
|
localStorage.setItem("sv-contributor", username);
|
|
609
752
|
localStorage.setItem("sv-file", path);
|
|
753
|
+
closeSidebar();
|
|
610
754
|
status("Loading " + path + "...");
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
headers: { "Content-Type": "application/json" },
|
|
614
|
-
body: JSON.stringify({ cmd: 'cat "' + username + "/" + path + '"' }),
|
|
615
|
-
});
|
|
755
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
756
|
+
const res = await api("/v1/files/" + encodeURIComponent(username) + "/" + encodedPath);
|
|
616
757
|
const text = await res.text();
|
|
617
758
|
contentEl.innerHTML = renderMarkdown(text);
|
|
618
759
|
contentEl.parentElement.scrollTop = 0;
|
|
@@ -656,11 +797,8 @@
|
|
|
656
797
|
scheduleContributorReload(contributor);
|
|
657
798
|
// If this file is currently open, reload its content
|
|
658
799
|
if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
headers: { "Content-Type": "application/json" },
|
|
662
|
-
body: JSON.stringify({ cmd: 'cat "' + contributor + "/" + path + '"' }),
|
|
663
|
-
})
|
|
800
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
801
|
+
api("/v1/files/" + encodeURIComponent(contributor) + "/" + encodedPath)
|
|
664
802
|
.then((res) => res.text())
|
|
665
803
|
.then((text) => { contentEl.innerHTML = renderMarkdown(text); });
|
|
666
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
|
|
|
@@ -41,17 +41,41 @@ function initDb(dbPath) {
|
|
|
41
41
|
used_by TEXT REFERENCES contributors(username)
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
CREATE TABLE IF NOT EXISTS
|
|
44
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
45
45
|
contributor TEXT NOT NULL,
|
|
46
46
|
path TEXT NOT NULL,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
server_modified_at TEXT NOT NULL,
|
|
47
|
+
content TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
modified_at TEXT NOT NULL,
|
|
51
50
|
PRIMARY KEY (contributor, path),
|
|
52
51
|
FOREIGN KEY (contributor) REFERENCES contributors(username)
|
|
53
52
|
);
|
|
54
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
|
+
}
|
|
55
79
|
return db;
|
|
56
80
|
}
|
|
57
81
|
function validateUsername(username) {
|
|
@@ -66,20 +90,57 @@ function validateUsername(username) {
|
|
|
66
90
|
}
|
|
67
91
|
return null;
|
|
68
92
|
}
|
|
69
|
-
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) {
|
|
70
121
|
const now = new Date().toISOString();
|
|
71
|
-
getDb().prepare("INSERT INTO contributors (username,
|
|
72
|
-
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 };
|
|
73
124
|
}
|
|
74
125
|
function getContributor(username) {
|
|
75
|
-
const row = getDb().prepare("SELECT username,
|
|
126
|
+
const row = getDb().prepare("SELECT username, is_admin, created_at FROM contributors WHERE username = ?").get(username);
|
|
76
127
|
if (row)
|
|
77
|
-
row.
|
|
128
|
+
row.is_admin = Boolean(row.is_admin);
|
|
78
129
|
return row;
|
|
79
130
|
}
|
|
80
131
|
function listContributors() {
|
|
81
|
-
const rows = getDb().prepare("SELECT username,
|
|
82
|
-
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;
|
|
83
144
|
}
|
|
84
145
|
function hasAnyContributor() {
|
|
85
146
|
const row = getDb().prepare("SELECT COUNT(*) as count FROM contributors").get();
|
|
@@ -89,7 +150,14 @@ function createApiKey(keyHash, label, contributor) {
|
|
|
89
150
|
const id = `key_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
90
151
|
const now = new Date().toISOString();
|
|
91
152
|
getDb().prepare("INSERT INTO api_keys (id, key_hash, label, contributor, created_at) VALUES (?, ?, ?, ?, ?)").run(id, keyHash, label, contributor, now);
|
|
92
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
key_hash: keyHash,
|
|
156
|
+
label,
|
|
157
|
+
contributor,
|
|
158
|
+
created_at: now,
|
|
159
|
+
last_used_at: null
|
|
160
|
+
};
|
|
93
161
|
}
|
|
94
162
|
function getApiKeyByHash(keyHash) {
|
|
95
163
|
return getDb().prepare("SELECT * FROM api_keys WHERE key_hash = ?").get(keyHash);
|
|
@@ -101,7 +169,13 @@ function createInvite(createdBy) {
|
|
|
101
169
|
const id = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
102
170
|
const now = new Date().toISOString();
|
|
103
171
|
getDb().prepare("INSERT INTO invites (id, created_by, created_at) VALUES (?, ?, ?)").run(id, createdBy, now);
|
|
104
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
created_by: createdBy,
|
|
175
|
+
created_at: now,
|
|
176
|
+
used_at: null,
|
|
177
|
+
used_by: null
|
|
178
|
+
};
|
|
105
179
|
}
|
|
106
180
|
function getInvite(id) {
|
|
107
181
|
return getDb().prepare("SELECT * FROM invites WHERE id = ?").get(id);
|
|
@@ -109,33 +183,83 @@ function getInvite(id) {
|
|
|
109
183
|
function markInviteUsed(id, usedBy) {
|
|
110
184
|
getDb().prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?").run(new Date().toISOString(), usedBy, id);
|
|
111
185
|
}
|
|
112
|
-
function
|
|
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
|
+
}
|
|
113
204
|
const now = new Date().toISOString();
|
|
114
|
-
|
|
115
|
-
|
|
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 (?, ?, ?, ?, ?)
|
|
116
209
|
ON CONFLICT (contributor, path) DO UPDATE SET
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
210
|
+
content = excluded.content,
|
|
211
|
+
modified_at = excluded.modified_at`).run(contributor, path, content, createdAt, modifiedAt);
|
|
212
|
+
return getItem(contributor, path);
|
|
120
213
|
}
|
|
121
|
-
function
|
|
122
|
-
return getDb().prepare("SELECT
|
|
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);
|
|
123
216
|
}
|
|
124
|
-
function
|
|
125
|
-
const map = new Map;
|
|
217
|
+
function listItems(contributor, prefix) {
|
|
126
218
|
let rows;
|
|
127
219
|
if (prefix) {
|
|
128
|
-
rows = getDb().prepare(
|
|
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 + "%");
|
|
129
223
|
} else {
|
|
130
|
-
rows = getDb().prepare(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
map.set(row.path, row);
|
|
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);
|
|
134
227
|
}
|
|
135
|
-
return
|
|
228
|
+
return rows;
|
|
136
229
|
}
|
|
137
|
-
function
|
|
138
|
-
getDb().prepare("DELETE FROM
|
|
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
|
+
}
|
|
139
263
|
}
|
|
140
264
|
|
|
141
265
|
// ../node_modules/.bun/hono@4.11.9/node_modules/hono/dist/compose.js
|
|
@@ -1700,140 +1824,6 @@ function getAuthCtx(c) {
|
|
|
1700
1824
|
return c.get("authCtx");
|
|
1701
1825
|
}
|
|
1702
1826
|
|
|
1703
|
-
// src/storage.ts
|
|
1704
|
-
import { join, dirname, relative, sep } from "path";
|
|
1705
|
-
import { mkdir, writeFile, unlink, readdir, stat, readFile, rename, rmdir } from "fs/promises";
|
|
1706
|
-
import { existsSync } from "fs";
|
|
1707
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
1708
|
-
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
1709
|
-
function validatePath(filePath) {
|
|
1710
|
-
if (!filePath || filePath.length === 0) {
|
|
1711
|
-
return "Path cannot be empty";
|
|
1712
|
-
}
|
|
1713
|
-
if (filePath.startsWith("/")) {
|
|
1714
|
-
return "Path cannot start with /";
|
|
1715
|
-
}
|
|
1716
|
-
if (filePath.includes("\\")) {
|
|
1717
|
-
return "Path cannot contain backslashes";
|
|
1718
|
-
}
|
|
1719
|
-
if (filePath.includes("//")) {
|
|
1720
|
-
return "Path cannot contain double slashes";
|
|
1721
|
-
}
|
|
1722
|
-
if (!filePath.endsWith(".md")) {
|
|
1723
|
-
return "Path must end in .md";
|
|
1724
|
-
}
|
|
1725
|
-
const segments = filePath.split("/");
|
|
1726
|
-
for (const seg of segments) {
|
|
1727
|
-
if (seg === "." || seg === "..") {
|
|
1728
|
-
return "Path cannot contain . or .. segments";
|
|
1729
|
-
}
|
|
1730
|
-
if (seg.length === 0) {
|
|
1731
|
-
return "Path cannot contain empty segments";
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
return null;
|
|
1735
|
-
}
|
|
1736
|
-
function resolvePath(storageRoot, contributor, filePath) {
|
|
1737
|
-
return join(storageRoot, contributor, filePath);
|
|
1738
|
-
}
|
|
1739
|
-
async function ensureContributorDir(storageRoot, contributor) {
|
|
1740
|
-
const dir = join(storageRoot, contributor);
|
|
1741
|
-
await mkdir(dir, { recursive: true });
|
|
1742
|
-
}
|
|
1743
|
-
async function writeFileAtomic(storageRoot, contributor, filePath, content) {
|
|
1744
|
-
const contentBuf = typeof content === "string" ? Buffer.from(content) : content;
|
|
1745
|
-
if (contentBuf.length > MAX_FILE_SIZE) {
|
|
1746
|
-
throw new FileTooLargeError(contentBuf.length);
|
|
1747
|
-
}
|
|
1748
|
-
const absPath = resolvePath(storageRoot, contributor, filePath);
|
|
1749
|
-
const dir = dirname(absPath);
|
|
1750
|
-
await mkdir(dir, { recursive: true });
|
|
1751
|
-
const tmpPath = `${absPath}.tmp.${randomUUID2().slice(0, 8)}`;
|
|
1752
|
-
try {
|
|
1753
|
-
await writeFile(tmpPath, contentBuf);
|
|
1754
|
-
await rename(tmpPath, absPath);
|
|
1755
|
-
} catch (e) {
|
|
1756
|
-
try {
|
|
1757
|
-
await unlink(tmpPath);
|
|
1758
|
-
} catch {}
|
|
1759
|
-
throw e;
|
|
1760
|
-
}
|
|
1761
|
-
const fileStat = await stat(absPath);
|
|
1762
|
-
return {
|
|
1763
|
-
path: filePath,
|
|
1764
|
-
size: fileStat.size,
|
|
1765
|
-
modifiedAt: fileStat.mtime.toISOString()
|
|
1766
|
-
};
|
|
1767
|
-
}
|
|
1768
|
-
async function deleteFile(storageRoot, contributor, filePath) {
|
|
1769
|
-
const absPath = resolvePath(storageRoot, contributor, filePath);
|
|
1770
|
-
if (!existsSync(absPath)) {
|
|
1771
|
-
throw new FileNotFoundError(filePath);
|
|
1772
|
-
}
|
|
1773
|
-
await unlink(absPath);
|
|
1774
|
-
const contributorRoot = join(storageRoot, contributor);
|
|
1775
|
-
let dir = dirname(absPath);
|
|
1776
|
-
while (dir !== contributorRoot && dir.startsWith(contributorRoot)) {
|
|
1777
|
-
try {
|
|
1778
|
-
await rmdir(dir);
|
|
1779
|
-
dir = dirname(dir);
|
|
1780
|
-
} catch {
|
|
1781
|
-
break;
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
async function listFiles(storageRoot, contributor, prefix) {
|
|
1786
|
-
const contributorRoot = join(storageRoot, contributor);
|
|
1787
|
-
if (!existsSync(contributorRoot)) {
|
|
1788
|
-
return [];
|
|
1789
|
-
}
|
|
1790
|
-
const files = [];
|
|
1791
|
-
await walkDir(contributorRoot, contributorRoot, prefix, files);
|
|
1792
|
-
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
1793
|
-
return files;
|
|
1794
|
-
}
|
|
1795
|
-
async function walkDir(dir, contributorRoot, prefix, results) {
|
|
1796
|
-
let entries;
|
|
1797
|
-
try {
|
|
1798
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
1799
|
-
} catch {
|
|
1800
|
-
return;
|
|
1801
|
-
}
|
|
1802
|
-
for (const entry of entries) {
|
|
1803
|
-
const fullPath = join(dir, entry.name);
|
|
1804
|
-
if (entry.isDirectory()) {
|
|
1805
|
-
await walkDir(fullPath, contributorRoot, prefix, results);
|
|
1806
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
1807
|
-
const relPath = relative(contributorRoot, fullPath).split(sep).join("/");
|
|
1808
|
-
if (prefix && !relPath.startsWith(prefix)) {
|
|
1809
|
-
continue;
|
|
1810
|
-
}
|
|
1811
|
-
const fileStat = await stat(fullPath);
|
|
1812
|
-
results.push({
|
|
1813
|
-
path: relPath,
|
|
1814
|
-
size: fileStat.size,
|
|
1815
|
-
modifiedAt: fileStat.mtime.toISOString()
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
class FileNotFoundError extends Error {
|
|
1822
|
-
constructor(path) {
|
|
1823
|
-
super(`File not found: ${path}`);
|
|
1824
|
-
this.name = "FileNotFoundError";
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
class FileTooLargeError extends Error {
|
|
1829
|
-
size;
|
|
1830
|
-
constructor(size) {
|
|
1831
|
-
super(`File too large: ${size} bytes (max ${MAX_FILE_SIZE})`);
|
|
1832
|
-
this.name = "FileTooLargeError";
|
|
1833
|
-
this.size = size;
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
1827
|
// src/sse.ts
|
|
1838
1828
|
var clients = new Set;
|
|
1839
1829
|
function addClient(controller) {
|
|
@@ -1857,178 +1847,6 @@ data: ${JSON.stringify(data)}
|
|
|
1857
1847
|
}
|
|
1858
1848
|
}
|
|
1859
1849
|
|
|
1860
|
-
// src/qmd.ts
|
|
1861
|
-
async function isQmdAvailable() {
|
|
1862
|
-
try {
|
|
1863
|
-
const proc = Bun.spawn(["qmd", "status"], { stdout: "pipe", stderr: "pipe" });
|
|
1864
|
-
await proc.exited;
|
|
1865
|
-
return true;
|
|
1866
|
-
} catch {
|
|
1867
|
-
return false;
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
async function addCollection(storageRoot, contributor) {
|
|
1871
|
-
const dir = `${storageRoot}/${contributor.username}`;
|
|
1872
|
-
const proc = Bun.spawn(["qmd", "collection", "add", dir, "--name", contributor.username, "--mask", "**/*.md"], { stdout: "pipe", stderr: "pipe" });
|
|
1873
|
-
const exitCode = await proc.exited;
|
|
1874
|
-
if (exitCode !== 0) {
|
|
1875
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1876
|
-
if (!stderr.includes("already exists")) {
|
|
1877
|
-
console.error(`QMD collection add failed for ${contributor.username}:`, stderr);
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
var updateInFlight = false;
|
|
1882
|
-
var updateQueued = false;
|
|
1883
|
-
function triggerUpdate() {
|
|
1884
|
-
if (updateInFlight) {
|
|
1885
|
-
updateQueued = true;
|
|
1886
|
-
return;
|
|
1887
|
-
}
|
|
1888
|
-
runUpdate();
|
|
1889
|
-
}
|
|
1890
|
-
async function runUpdate() {
|
|
1891
|
-
updateInFlight = true;
|
|
1892
|
-
try {
|
|
1893
|
-
const proc = Bun.spawn(["qmd", "update"], { stdout: "pipe", stderr: "pipe" });
|
|
1894
|
-
const exitCode = await proc.exited;
|
|
1895
|
-
if (exitCode !== 0) {
|
|
1896
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1897
|
-
console.error("QMD update failed:", stderr);
|
|
1898
|
-
}
|
|
1899
|
-
} catch (e) {
|
|
1900
|
-
console.error("QMD update error:", e);
|
|
1901
|
-
} finally {
|
|
1902
|
-
updateInFlight = false;
|
|
1903
|
-
if (updateQueued) {
|
|
1904
|
-
updateQueued = false;
|
|
1905
|
-
runUpdate();
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
async function search(query, options = {}) {
|
|
1910
|
-
const limit = options.limit ?? 10;
|
|
1911
|
-
const args = ["search", query, "--json", "-n", String(limit)];
|
|
1912
|
-
if (options.collection) {
|
|
1913
|
-
args.push("-c", options.collection);
|
|
1914
|
-
}
|
|
1915
|
-
try {
|
|
1916
|
-
const proc = Bun.spawn(["qmd", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
1917
|
-
const stdout = await new Response(proc.stdout).text();
|
|
1918
|
-
const exitCode = await proc.exited;
|
|
1919
|
-
if (exitCode !== 0) {
|
|
1920
|
-
const stderr = await new Response(proc.stderr).text();
|
|
1921
|
-
console.error("QMD search failed:", stderr);
|
|
1922
|
-
return [];
|
|
1923
|
-
}
|
|
1924
|
-
if (!stdout.trim())
|
|
1925
|
-
return [];
|
|
1926
|
-
return JSON.parse(stdout);
|
|
1927
|
-
} catch (e) {
|
|
1928
|
-
console.error("QMD search error:", e);
|
|
1929
|
-
return [];
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
async function syncContributors(storageRoot, contributors) {
|
|
1933
|
-
for (const contributor of contributors) {
|
|
1934
|
-
await addCollection(storageRoot, contributor);
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
// src/shell.ts
|
|
1939
|
-
var ALLOWED_COMMANDS = new Set([
|
|
1940
|
-
"ls",
|
|
1941
|
-
"cat",
|
|
1942
|
-
"head",
|
|
1943
|
-
"tail",
|
|
1944
|
-
"find",
|
|
1945
|
-
"grep",
|
|
1946
|
-
"wc",
|
|
1947
|
-
"tree",
|
|
1948
|
-
"stat"
|
|
1949
|
-
]);
|
|
1950
|
-
var MAX_STDOUT = 1024 * 1024;
|
|
1951
|
-
var TIMEOUT_MS = 1e4;
|
|
1952
|
-
function parseCommand(cmd) {
|
|
1953
|
-
const args = [];
|
|
1954
|
-
let current = "";
|
|
1955
|
-
let inSingle = false;
|
|
1956
|
-
let inDouble = false;
|
|
1957
|
-
for (let i = 0;i < cmd.length; i++) {
|
|
1958
|
-
const ch = cmd[i];
|
|
1959
|
-
if (ch === "'" && !inDouble) {
|
|
1960
|
-
inSingle = !inSingle;
|
|
1961
|
-
} else if (ch === '"' && !inSingle) {
|
|
1962
|
-
inDouble = !inDouble;
|
|
1963
|
-
} else if (ch === " " && !inSingle && !inDouble) {
|
|
1964
|
-
if (current.length > 0) {
|
|
1965
|
-
args.push(current);
|
|
1966
|
-
current = "";
|
|
1967
|
-
}
|
|
1968
|
-
} else {
|
|
1969
|
-
current += ch;
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
if (current.length > 0) {
|
|
1973
|
-
args.push(current);
|
|
1974
|
-
}
|
|
1975
|
-
return args;
|
|
1976
|
-
}
|
|
1977
|
-
async function executeCommand(cmd, storageRoot) {
|
|
1978
|
-
const argv = parseCommand(cmd.trim());
|
|
1979
|
-
if (argv.length === 0) {
|
|
1980
|
-
throw new ShellValidationError("Empty command");
|
|
1981
|
-
}
|
|
1982
|
-
const command = argv[0];
|
|
1983
|
-
if (!ALLOWED_COMMANDS.has(command)) {
|
|
1984
|
-
throw new ShellValidationError(`Command not allowed: ${command}. Allowed: ${[...ALLOWED_COMMANDS].join(", ")}`);
|
|
1985
|
-
}
|
|
1986
|
-
for (const arg of argv.slice(1)) {
|
|
1987
|
-
if (arg.includes("..")) {
|
|
1988
|
-
throw new ShellValidationError("Path traversal (..) is not allowed");
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
const proc = Bun.spawn(argv, {
|
|
1992
|
-
cwd: storageRoot,
|
|
1993
|
-
stdout: "pipe",
|
|
1994
|
-
stderr: "pipe",
|
|
1995
|
-
env: {}
|
|
1996
|
-
});
|
|
1997
|
-
const timeout = setTimeout(() => {
|
|
1998
|
-
proc.kill();
|
|
1999
|
-
}, TIMEOUT_MS);
|
|
2000
|
-
try {
|
|
2001
|
-
const [stdoutBuf, stderrBuf] = await Promise.all([
|
|
2002
|
-
new Response(proc.stdout).arrayBuffer(),
|
|
2003
|
-
new Response(proc.stderr).arrayBuffer()
|
|
2004
|
-
]);
|
|
2005
|
-
await proc.exited;
|
|
2006
|
-
const truncated = stdoutBuf.byteLength > MAX_STDOUT;
|
|
2007
|
-
const stdoutBytes = truncated ? stdoutBuf.slice(0, MAX_STDOUT) : stdoutBuf;
|
|
2008
|
-
let stdout = new TextDecoder().decode(stdoutBytes);
|
|
2009
|
-
if (truncated) {
|
|
2010
|
-
stdout += `
|
|
2011
|
-
[truncated]`;
|
|
2012
|
-
}
|
|
2013
|
-
const stderr = new TextDecoder().decode(stderrBuf);
|
|
2014
|
-
return {
|
|
2015
|
-
stdout,
|
|
2016
|
-
stderr,
|
|
2017
|
-
exitCode: proc.exitCode ?? 1,
|
|
2018
|
-
truncated
|
|
2019
|
-
};
|
|
2020
|
-
} finally {
|
|
2021
|
-
clearTimeout(timeout);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
class ShellValidationError extends Error {
|
|
2026
|
-
constructor(message) {
|
|
2027
|
-
super(message);
|
|
2028
|
-
this.name = "ShellValidationError";
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
1850
|
// src/routes.ts
|
|
2033
1851
|
var uiPath = resolve(import.meta.dirname, "index.html");
|
|
2034
1852
|
var isDev = true;
|
|
@@ -2049,7 +1867,7 @@ function extractFileInfo(reqPath) {
|
|
|
2049
1867
|
filePath: decoded.slice(slashIdx + 1)
|
|
2050
1868
|
};
|
|
2051
1869
|
}
|
|
2052
|
-
function createApp(
|
|
1870
|
+
function createApp() {
|
|
2053
1871
|
const app = new Hono2;
|
|
2054
1872
|
app.get("/", (c) => {
|
|
2055
1873
|
return c.html(isDev ? readFileSync(uiPath, "utf-8") : uiHtmlCached);
|
|
@@ -2084,8 +1902,6 @@ function createApp(storageRoot) {
|
|
|
2084
1902
|
return c.json({ error: "A contributor with that username already exists" }, 409);
|
|
2085
1903
|
}
|
|
2086
1904
|
const contributor = createContributor(username, isFirstUser);
|
|
2087
|
-
await ensureContributorDir(storageRoot, contributor.username);
|
|
2088
|
-
addCollection(storageRoot, contributor).catch((e) => console.error("Failed to register QMD collection:", e));
|
|
2089
1905
|
const rawToken = generateToken();
|
|
2090
1906
|
createApiKey(hashToken(rawToken), `${username}-default`, contributor.username);
|
|
2091
1907
|
if (!isFirstUser && body.invite) {
|
|
@@ -2110,8 +1926,8 @@ function createApp(storageRoot) {
|
|
|
2110
1926
|
});
|
|
2111
1927
|
authed.post("/v1/invites", (c) => {
|
|
2112
1928
|
const { contributor } = getAuthCtx(c);
|
|
2113
|
-
if (!contributor.
|
|
2114
|
-
return c.json({ error: "Only the
|
|
1929
|
+
if (!contributor.is_admin) {
|
|
1930
|
+
return c.json({ error: "Only the admin can generate invite codes" }, 403);
|
|
2115
1931
|
}
|
|
2116
1932
|
const invite = createInvite(contributor.username);
|
|
2117
1933
|
return c.json({
|
|
@@ -2128,27 +1944,20 @@ function createApp(storageRoot) {
|
|
|
2128
1944
|
}))
|
|
2129
1945
|
});
|
|
2130
1946
|
});
|
|
2131
|
-
authed.
|
|
2132
|
-
const
|
|
2133
|
-
|
|
2134
|
-
|
|
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);
|
|
2135
1952
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
return new Response(result.stdout, {
|
|
2139
|
-
status: 200,
|
|
2140
|
-
headers: {
|
|
2141
|
-
"Content-Type": "text/plain; charset=utf-8",
|
|
2142
|
-
"X-Exit-Code": String(result.exitCode),
|
|
2143
|
-
"X-Stderr": encodeURIComponent(result.stderr)
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
} catch (e) {
|
|
2147
|
-
if (e instanceof ShellValidationError) {
|
|
2148
|
-
return c.json({ error: e.message }, 400);
|
|
2149
|
-
}
|
|
2150
|
-
throw e;
|
|
1953
|
+
if (target === contributor.username) {
|
|
1954
|
+
return c.json({ error: "Cannot delete yourself" }, 400);
|
|
2151
1955
|
}
|
|
1956
|
+
const found = deleteContributor(target);
|
|
1957
|
+
if (!found) {
|
|
1958
|
+
return c.json({ error: "Contributor not found" }, 404);
|
|
1959
|
+
}
|
|
1960
|
+
return c.body(null, 204);
|
|
2152
1961
|
});
|
|
2153
1962
|
authed.put("/v1/files/*", async (c) => {
|
|
2154
1963
|
const { contributor } = getAuthCtx(c);
|
|
@@ -2171,30 +1980,27 @@ function createApp(storageRoot) {
|
|
|
2171
1980
|
const originCtime = c.req.header("X-Origin-Ctime") || now;
|
|
2172
1981
|
const originMtime = c.req.header("X-Origin-Mtime") || now;
|
|
2173
1982
|
try {
|
|
2174
|
-
const
|
|
2175
|
-
const meta = upsertFileMetadata(parsed.username, parsed.filePath, originCtime, originMtime);
|
|
1983
|
+
const item = upsertItem(parsed.username, parsed.filePath, content, originCtime, originMtime);
|
|
2176
1984
|
broadcast("file_updated", {
|
|
2177
1985
|
contributor: parsed.username,
|
|
2178
|
-
path:
|
|
2179
|
-
size:
|
|
2180
|
-
modifiedAt:
|
|
1986
|
+
path: item.path,
|
|
1987
|
+
size: Buffer.byteLength(item.content),
|
|
1988
|
+
modifiedAt: item.modified_at
|
|
2181
1989
|
});
|
|
2182
|
-
triggerUpdate();
|
|
2183
1990
|
return c.json({
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
serverModifiedAt: meta.server_modified_at
|
|
1991
|
+
path: item.path,
|
|
1992
|
+
size: Buffer.byteLength(item.content),
|
|
1993
|
+
createdAt: item.created_at,
|
|
1994
|
+
modifiedAt: item.modified_at
|
|
2189
1995
|
});
|
|
2190
1996
|
} catch (e) {
|
|
2191
|
-
if (e instanceof
|
|
1997
|
+
if (e instanceof ItemTooLargeError) {
|
|
2192
1998
|
return c.json({ error: e.message }, 413);
|
|
2193
1999
|
}
|
|
2194
2000
|
throw e;
|
|
2195
2001
|
}
|
|
2196
2002
|
});
|
|
2197
|
-
authed.delete("/v1/files/*",
|
|
2003
|
+
authed.delete("/v1/files/*", (c) => {
|
|
2198
2004
|
const { contributor } = getAuthCtx(c);
|
|
2199
2005
|
const parsed = extractFileInfo(c.req.path);
|
|
2200
2006
|
if (!parsed) {
|
|
@@ -2207,23 +2013,17 @@ function createApp(storageRoot) {
|
|
|
2207
2013
|
if (pathError) {
|
|
2208
2014
|
return c.json({ error: pathError }, 400);
|
|
2209
2015
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
broadcast("file_deleted", {
|
|
2214
|
-
contributor: parsed.username,
|
|
2215
|
-
path: parsed.filePath
|
|
2216
|
-
});
|
|
2217
|
-
triggerUpdate();
|
|
2218
|
-
return c.body(null, 204);
|
|
2219
|
-
} catch (e) {
|
|
2220
|
-
if (e instanceof FileNotFoundError) {
|
|
2221
|
-
return c.json({ error: "File not found" }, 404);
|
|
2222
|
-
}
|
|
2223
|
-
throw e;
|
|
2016
|
+
const found = deleteItem(parsed.username, parsed.filePath);
|
|
2017
|
+
if (!found) {
|
|
2018
|
+
return c.json({ error: "File not found" }, 404);
|
|
2224
2019
|
}
|
|
2020
|
+
broadcast("file_deleted", {
|
|
2021
|
+
contributor: parsed.username,
|
|
2022
|
+
path: parsed.filePath
|
|
2023
|
+
});
|
|
2024
|
+
return c.body(null, 204);
|
|
2225
2025
|
});
|
|
2226
|
-
authed.get("/v1/files",
|
|
2026
|
+
authed.get("/v1/files", (c) => {
|
|
2227
2027
|
const prefix = c.req.query("prefix") || "";
|
|
2228
2028
|
if (!prefix) {
|
|
2229
2029
|
return c.json({ error: "prefix query parameter is required" }, 400);
|
|
@@ -2234,20 +2034,33 @@ function createApp(storageRoot) {
|
|
|
2234
2034
|
if (!getContributor(username)) {
|
|
2235
2035
|
return c.json({ error: "Contributor not found" }, 404);
|
|
2236
2036
|
}
|
|
2237
|
-
const
|
|
2238
|
-
const metaMap = listFileMetadata(username, subPrefix);
|
|
2037
|
+
const items = listItems(username, subPrefix);
|
|
2239
2038
|
return c.json({
|
|
2240
|
-
files:
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2039
|
+
files: items.map((f) => ({
|
|
2040
|
+
path: `${username}/${f.path}`,
|
|
2041
|
+
size: f.size,
|
|
2042
|
+
createdAt: f.created_at,
|
|
2043
|
+
modifiedAt: f.modified_at
|
|
2044
|
+
}))
|
|
2045
|
+
});
|
|
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
|
+
}
|
|
2251
2064
|
});
|
|
2252
2065
|
});
|
|
2253
2066
|
authed.get("/v1/events", (c) => {
|
|
@@ -2285,22 +2098,17 @@ data: {}
|
|
|
2285
2098
|
}
|
|
2286
2099
|
});
|
|
2287
2100
|
});
|
|
2288
|
-
authed.get("/v1/search",
|
|
2101
|
+
authed.get("/v1/search", (c) => {
|
|
2289
2102
|
const q = c.req.query("q");
|
|
2290
2103
|
if (!q) {
|
|
2291
2104
|
return c.json({ error: "q parameter is required" }, 400);
|
|
2292
2105
|
}
|
|
2293
2106
|
const contributorParam = c.req.query("contributor") || undefined;
|
|
2294
2107
|
const limit = parseInt(c.req.query("limit") || "10", 10);
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
const contributor = getContributor(contributorParam);
|
|
2298
|
-
if (!contributor) {
|
|
2299
|
-
return c.json({ error: "Contributor not found" }, 404);
|
|
2300
|
-
}
|
|
2301
|
-
collectionName = contributor.username;
|
|
2108
|
+
if (contributorParam && !getContributor(contributorParam)) {
|
|
2109
|
+
return c.json({ error: "Contributor not found" }, 404);
|
|
2302
2110
|
}
|
|
2303
|
-
const results =
|
|
2111
|
+
const results = searchItems(q, contributorParam, limit);
|
|
2304
2112
|
return c.json({ results });
|
|
2305
2113
|
});
|
|
2306
2114
|
app.route("/", authed);
|
|
@@ -2309,28 +2117,14 @@ data: {}
|
|
|
2309
2117
|
|
|
2310
2118
|
// src/index.ts
|
|
2311
2119
|
var PORT = parseInt(process.env.PORT || "3000", 10);
|
|
2312
|
-
var DATA_DIR = process.env.DATA_DIR ||
|
|
2313
|
-
var dbPath =
|
|
2314
|
-
|
|
2315
|
-
await mkdir2(DATA_DIR, { recursive: true });
|
|
2316
|
-
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 });
|
|
2317
2123
|
initDb(dbPath);
|
|
2318
|
-
var app = createApp(
|
|
2124
|
+
var app = createApp();
|
|
2319
2125
|
console.log(`Seedvault server starting on port ${PORT}`);
|
|
2320
2126
|
console.log(` Data dir: ${DATA_DIR}`);
|
|
2321
2127
|
console.log(` Database: ${dbPath}`);
|
|
2322
|
-
console.log(` Storage: ${storageRoot}`);
|
|
2323
|
-
var qmdAvailable = await isQmdAvailable();
|
|
2324
|
-
if (qmdAvailable) {
|
|
2325
|
-
console.log(" QMD: available");
|
|
2326
|
-
const contributors = listContributors();
|
|
2327
|
-
if (contributors.length > 0) {
|
|
2328
|
-
await syncContributors(storageRoot, contributors);
|
|
2329
|
-
console.log(` QMD: synced ${contributors.length} collection(s)`);
|
|
2330
|
-
}
|
|
2331
|
-
} else {
|
|
2332
|
-
console.log(" QMD: not found (search disabled)");
|
|
2333
|
-
}
|
|
2334
2128
|
var server = Bun.serve({
|
|
2335
2129
|
port: PORT,
|
|
2336
2130
|
fetch: app.fetch
|