@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.
Files changed (3) hide show
  1. package/dist/index.html +194 -56
  2. package/dist/server.js +227 -433
  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,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.originCtime || node.__file.serverCreatedAt || "";
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
- row.innerHTML = '<span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key;
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>';
461
567
  row.dataset.path = child.__file.path;
462
568
  row.dataset.contributor = username;
463
- row.onclick = () => loadContent(username, child.__file.path, row);
569
+ row.dataset.action = "open";
464
570
  } else {
465
571
  const sub = document.createElement("ul");
466
572
  const fileCount = countFiles(child);
467
- row.innerHTML = '<span class="arrow">&#9660;</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span>';
468
- row.onclick = () => {
469
- sub.classList.toggle("collapsed");
470
- row.querySelector(".arrow").innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
471
- };
472
- 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);
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") ? "&#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
+
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
- renderTree(tree, sub, username, 1);
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 = "false";
568
- let loaded = false;
569
- row.innerHTML = '<span class="arrow">&#9654;</span><i class="ph ph-user"></i> ' + b.username;
570
- row.onclick = async () => {
571
- const collapsed = sub.classList.contains("collapsed") || !sub.hasChildNodes();
572
- if (collapsed) {
573
- sub.classList.remove("collapsed");
574
- row.querySelector(".arrow").innerHTML = "&#9660;";
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 = "&#9654;";
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 ? "&#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;
585
729
  li.appendChild(row);
586
730
  li.appendChild(sub);
587
731
  filesEl.appendChild(li);
732
+ }
588
733
 
589
- if (b.username === savedContributor) {
590
- sub.classList.remove("collapsed");
591
- row.querySelector(".arrow").innerHTML = "&#9660;";
592
- loaded = true;
593
- sub.dataset.loaded = "true";
594
- await loadContributorFiles(b.username, sub);
595
- if (savedFile) {
596
- const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
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 res = await api("/v1/sh", {
612
- method: "POST",
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
- api("/v1/sh", {
660
- method: "POST",
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 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
 
@@ -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 files (
44
+ CREATE TABLE IF NOT EXISTS items (
45
45
  contributor TEXT NOT NULL,
46
46
  path TEXT NOT NULL,
47
- origin_ctime TEXT NOT NULL,
48
- origin_mtime TEXT NOT NULL,
49
- server_created_at TEXT NOT NULL,
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 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) {
70
121
  const now = new Date().toISOString();
71
- getDb().prepare("INSERT INTO contributors (username, is_operator, created_at) VALUES (?, ?, ?)").run(username, isOperator ? 1 : 0, now);
72
- 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 };
73
124
  }
74
125
  function getContributor(username) {
75
- 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);
76
127
  if (row)
77
- row.is_operator = Boolean(row.is_operator);
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, is_operator, created_at FROM contributors ORDER BY created_at ASC").all();
82
- 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;
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 { 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
+ };
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 { 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
+ };
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 upsertFileMetadata(contributor, path, originCtime, originMtime) {
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
- getDb().prepare(`INSERT INTO files (contributor, path, origin_ctime, origin_mtime, server_created_at, server_modified_at)
115
- VALUES (?, ?, ?, ?, ?, ?)
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
- origin_mtime = excluded.origin_mtime,
118
- server_modified_at = excluded.server_modified_at`).run(contributor, path, originCtime, originMtime, now, now);
119
- return getFileMetadata(contributor, path);
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 getFileMetadata(contributor, path) {
122
- return getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path = ?").get(contributor, path);
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 listFileMetadata(contributor, prefix) {
125
- const map = new Map;
217
+ function listItems(contributor, prefix) {
126
218
  let rows;
127
219
  if (prefix) {
128
- rows = getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path LIKE ?").all(contributor, 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 + "%");
129
223
  } else {
130
- rows = getDb().prepare("SELECT * FROM files WHERE contributor = ?").all(contributor);
131
- }
132
- for (const row of rows) {
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 map;
228
+ return rows;
136
229
  }
137
- function deleteFileMetadata(contributor, path) {
138
- getDb().prepare("DELETE FROM files WHERE contributor = ? AND path = ?").run(contributor, path);
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(storageRoot) {
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.is_operator) {
2114
- 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);
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.post("/v1/sh", async (c) => {
2132
- const body = await c.req.json();
2133
- if (!body.cmd || typeof body.cmd !== "string") {
2134
- 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);
2135
1952
  }
2136
- try {
2137
- const result = await executeCommand(body.cmd, storageRoot);
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 result = await writeFileAtomic(storageRoot, parsed.username, parsed.filePath, content);
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: result.path,
2179
- size: result.size,
2180
- modifiedAt: result.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
- ...result,
2185
- originCtime: meta.origin_ctime,
2186
- originMtime: meta.origin_mtime,
2187
- serverCreatedAt: meta.server_created_at,
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 FileTooLargeError) {
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/*", async (c) => {
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
- try {
2211
- await deleteFile(storageRoot, parsed.username, parsed.filePath);
2212
- deleteFileMetadata(parsed.username, parsed.filePath);
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", async (c) => {
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 files = await listFiles(storageRoot, username, subPrefix);
2238
- const metaMap = listFileMetadata(username, subPrefix);
2037
+ const items = listItems(username, subPrefix);
2239
2038
  return c.json({
2240
- files: files.map((f) => {
2241
- const meta = metaMap.get(f.path);
2242
- return {
2243
- ...f,
2244
- path: `${username}/${f.path}`,
2245
- originCtime: meta?.origin_ctime,
2246
- originMtime: meta?.origin_mtime,
2247
- serverCreatedAt: meta?.server_created_at,
2248
- serverModifiedAt: meta?.server_modified_at
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", async (c) => {
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
- let collectionName;
2296
- if (contributorParam) {
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 = await search(q, { collection: collectionName, limit });
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 || join2(homedir(), ".seedvault", "data");
2313
- var dbPath = join2(DATA_DIR, "seedvault.db");
2314
- var storageRoot = join2(DATA_DIR, "files");
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(storageRoot);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/server",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "seedvault-server": "bin/seedvault-server.mjs"