@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.
Files changed (3) hide show
  1. package/dist/index.html +216 -61
  2. package/dist/server.js +247 -395
  3. 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: block;
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">&#9776;</button>
338
404
  <input id="token" type="password" placeholder="API key (&quot;sv_...&quot;)" />
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</div>
410
+ <div class="panel-head">Files<button id="nav-close" aria-label="Close navigation">&times;</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 buildTree(paths) {
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 p of paths) {
410
- const parts = p.split("/");
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 = p;
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
- row.innerHTML = '<span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key;
447
- row.dataset.path = child.__file;
565
+ const ctime = child.__file.createdAt || "";
566
+ row.innerHTML = '<span class="name"><span class="arrow">&nbsp;</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.onclick = () => loadContent(username, child.__file, row);
569
+ row.dataset.action = "open";
450
570
  } else {
451
571
  const sub = document.createElement("ul");
452
572
  const fileCount = countFiles(child);
453
- row.innerHTML = '<span class="arrow">&#9660;</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span>';
454
- row.onclick = () => {
455
- sub.classList.toggle("collapsed");
456
- row.querySelector(".arrow").innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
457
- };
458
- renderTree(child, sub, username, depth + 1);
573
+ const expanded = getExpandedKeys().has(username + ":" + nodePath);
574
+ const arrow = expanded ? "&#9660;" : "&#9654;";
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") ? "&#9654;" : "&#9660;";
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 = "&#9660;";
668
+ keys.add(toggleKey);
669
+ } else {
670
+ sub.classList.add("collapsed");
671
+ row.querySelector(".arrow").innerHTML = "&#9654;";
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) => f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path));
526
- renderTree(tree, sub, username, 1);
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 = "false";
551
- let loaded = false;
552
- row.innerHTML = '<span class="arrow">&#9654;</span><i class="ph ph-user"></i> ' + b.username;
553
- row.onclick = async () => {
554
- const collapsed = sub.classList.contains("collapsed") || !sub.hasChildNodes();
555
- if (collapsed) {
556
- sub.classList.remove("collapsed");
557
- row.querySelector(".arrow").innerHTML = "&#9660;";
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 = "&#9654;";
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 ? "&#9660;" : "&#9654;";
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
- if (b.username === savedContributor) {
573
- sub.classList.remove("collapsed");
574
- row.querySelector(".arrow").innerHTML = "&#9660;";
575
- loaded = true;
576
- sub.dataset.loaded = "true";
577
- await loadContributorFiles(b.username, sub);
578
- if (savedFile) {
579
- const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
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 res = await api("/v1/sh", {
595
- method: "POST",
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
- api("/v1/sh", {
643
- method: "POST",
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 as join2 } from "path";
3
+ import { join } from "path";
4
4
  import { homedir } from "os";
5
- import { mkdir as mkdir2 } from "fs/promises";
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
- is_operator BOOLEAN NOT NULL DEFAULT FALSE,
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 createContributor(username, isOperator) {
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, is_operator, created_at) VALUES (?, ?, ?)").run(username, isOperator ? 1 : 0, now);
61
- return { username, is_operator: isOperator, created_at: now };
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, is_operator, created_at FROM contributors WHERE username = ?").get(username);
126
+ const row = getDb().prepare("SELECT username, is_admin, created_at FROM contributors WHERE username = ?").get(username);
65
127
  if (row)
66
- row.is_operator = Boolean(row.is_operator);
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, is_operator, created_at FROM contributors ORDER BY created_at ASC").all();
71
- return rows.map((r) => ({ ...r, is_operator: Boolean(r.is_operator) }));
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 { id, key_hash: keyHash, label, contributor, created_at: now, last_used_at: null };
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 { id, created_by: createdBy, created_at: now, used_at: null, used_by: null };
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(storageRoot) {
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.is_operator) {
2075
- return c.json({ error: "Only the operator can generate invite codes" }, 403);
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.post("/v1/sh", async (c) => {
2093
- const body = await c.req.json();
2094
- if (!body.cmd || typeof body.cmd !== "string") {
2095
- return c.json({ error: "cmd is required" }, 400);
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
- try {
2098
- const result = await executeCommand(body.cmd, storageRoot);
2099
- return new Response(result.stdout, {
2100
- status: 200,
2101
- headers: {
2102
- "Content-Type": "text/plain; charset=utf-8",
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 result = await writeFileAtomic(storageRoot, parsed.username, parsed.filePath, content);
1983
+ const item = upsertItem(parsed.username, parsed.filePath, content, originCtime, originMtime);
2133
1984
  broadcast("file_updated", {
2134
1985
  contributor: parsed.username,
2135
- path: result.path,
2136
- size: result.size,
2137
- modifiedAt: result.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 FileTooLargeError) {
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/*", async (c) => {
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
- try {
2162
- await deleteFile(storageRoot, parsed.username, parsed.filePath);
2163
- broadcast("file_deleted", {
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", async (c) => {
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 files = await listFiles(storageRoot, username, subPrefix);
2037
+ const items = listItems(username, subPrefix);
2188
2038
  return c.json({
2189
- files: files.map((f) => ({
2190
- ...f,
2191
- path: `${username}/${f.path}`
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", async (c) => {
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
- let collectionName;
2238
- if (contributorParam) {
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 = await search(q, { collection: collectionName, limit });
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 || join2(homedir(), ".seedvault", "data");
2255
- var dbPath = join2(DATA_DIR, "seedvault.db");
2256
- var storageRoot = join2(DATA_DIR, "files");
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(storageRoot);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/server",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "seedvault-server": "bin/seedvault-server.mjs"