@seedvault/server 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.html +617 -59
  2. package/dist/server.js +445 -423
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1,12 +1,12 @@
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";
9
- import { randomUUID } from "crypto";
9
+ import { randomBytes, randomUUID } from "crypto";
10
10
  var db;
11
11
  function getDb() {
12
12
  if (!db)
@@ -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,52 @@ 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
  );
53
+
54
+ CREATE TABLE IF NOT EXISTS activity (
55
+ id TEXT PRIMARY KEY,
56
+ contributor TEXT NOT NULL REFERENCES contributors(username),
57
+ action TEXT NOT NULL,
58
+ detail TEXT,
59
+ created_at TEXT NOT NULL
60
+ );
61
+ CREATE INDEX IF NOT EXISTS idx_activity_contributor ON activity(contributor);
62
+ CREATE INDEX IF NOT EXISTS idx_activity_created_at ON activity(created_at);
63
+ CREATE INDEX IF NOT EXISTS idx_activity_action ON activity(action);
54
64
  `);
65
+ const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'").get();
66
+ if (!hasFts) {
67
+ db.exec(`
68
+ CREATE VIRTUAL TABLE items_fts USING fts5(
69
+ path, content, content=items, content_rowid=rowid
70
+ );
71
+
72
+ CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
73
+ INSERT INTO items_fts(rowid, path, content)
74
+ VALUES (new.rowid, new.path, new.content);
75
+ END;
76
+
77
+ CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
78
+ INSERT INTO items_fts(items_fts, rowid, path, content)
79
+ VALUES ('delete', old.rowid, old.path, old.content);
80
+ END;
81
+
82
+ CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
83
+ INSERT INTO items_fts(items_fts, rowid, path, content)
84
+ VALUES ('delete', old.rowid, old.path, old.content);
85
+ INSERT INTO items_fts(rowid, path, content)
86
+ VALUES (new.rowid, new.path, new.content);
87
+ END;
88
+ `);
89
+ }
55
90
  return db;
56
91
  }
57
92
  function validateUsername(username) {
@@ -66,20 +101,58 @@ function validateUsername(username) {
66
101
  }
67
102
  return null;
68
103
  }
69
- function createContributor(username, isOperator) {
104
+ function validatePath(filePath) {
105
+ if (!filePath || filePath.length === 0) {
106
+ return "Path cannot be empty";
107
+ }
108
+ if (filePath.startsWith("/")) {
109
+ return "Path cannot start with /";
110
+ }
111
+ if (filePath.includes("\\")) {
112
+ return "Path cannot contain backslashes";
113
+ }
114
+ if (filePath.includes("//")) {
115
+ return "Path cannot contain double slashes";
116
+ }
117
+ if (!filePath.endsWith(".md")) {
118
+ return "Path must end in .md";
119
+ }
120
+ const segments = filePath.split("/");
121
+ for (const seg of segments) {
122
+ if (seg === "." || seg === "..") {
123
+ return "Path cannot contain . or .. segments";
124
+ }
125
+ if (seg.length === 0) {
126
+ return "Path cannot contain empty segments";
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ function createContributor(username, isAdmin) {
70
132
  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 };
133
+ getDb().prepare("INSERT INTO contributors (username, is_admin, created_at) VALUES (?, ?, ?)").run(username, isAdmin ? 1 : 0, now);
134
+ return { username, is_admin: isAdmin, created_at: now };
73
135
  }
74
136
  function getContributor(username) {
75
- const row = getDb().prepare("SELECT username, is_operator, created_at FROM contributors WHERE username = ?").get(username);
137
+ const row = getDb().prepare("SELECT username, is_admin, created_at FROM contributors WHERE username = ?").get(username);
76
138
  if (row)
77
- row.is_operator = Boolean(row.is_operator);
139
+ row.is_admin = Boolean(row.is_admin);
78
140
  return row;
79
141
  }
80
142
  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) }));
143
+ const rows = getDb().prepare("SELECT username, is_admin, created_at FROM contributors ORDER BY created_at ASC").all();
144
+ return rows.map((r) => ({ ...r, is_admin: Boolean(r.is_admin) }));
145
+ }
146
+ function deleteContributor(username) {
147
+ const d = getDb();
148
+ const exists = d.prepare("SELECT 1 FROM contributors WHERE username = ?").get(username);
149
+ if (!exists)
150
+ return false;
151
+ d.prepare("DELETE FROM activity WHERE contributor = ?").run(username);
152
+ d.prepare("DELETE FROM items WHERE contributor = ?").run(username);
153
+ d.prepare("DELETE FROM api_keys WHERE contributor = ?").run(username);
154
+ d.prepare("DELETE FROM contributors WHERE username = ?").run(username);
155
+ return true;
83
156
  }
84
157
  function hasAnyContributor() {
85
158
  const row = getDb().prepare("SELECT COUNT(*) as count FROM contributors").get();
@@ -89,7 +162,14 @@ function createApiKey(keyHash, label, contributor) {
89
162
  const id = `key_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
90
163
  const now = new Date().toISOString();
91
164
  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 };
165
+ return {
166
+ id,
167
+ key_hash: keyHash,
168
+ label,
169
+ contributor,
170
+ created_at: now,
171
+ last_used_at: null
172
+ };
93
173
  }
94
174
  function getApiKeyByHash(keyHash) {
95
175
  return getDb().prepare("SELECT * FROM api_keys WHERE key_hash = ?").get(keyHash);
@@ -101,7 +181,13 @@ function createInvite(createdBy) {
101
181
  const id = randomUUID().replace(/-/g, "").slice(0, 12);
102
182
  const now = new Date().toISOString();
103
183
  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 };
184
+ return {
185
+ id,
186
+ created_by: createdBy,
187
+ created_at: now,
188
+ used_at: null,
189
+ used_by: null
190
+ };
105
191
  }
106
192
  function getInvite(id) {
107
193
  return getDb().prepare("SELECT * FROM invites WHERE id = ?").get(id);
@@ -109,33 +195,110 @@ function getInvite(id) {
109
195
  function markInviteUsed(id, usedBy) {
110
196
  getDb().prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?").run(new Date().toISOString(), usedBy, id);
111
197
  }
112
- function upsertFileMetadata(contributor, path, originCtime, originMtime) {
198
+ function validateOriginCtime(originCtime, originMtime) {
199
+ if (originCtime) {
200
+ const ms = new Date(originCtime).getTime();
201
+ if (ms > 0 && !isNaN(ms))
202
+ return originCtime;
203
+ }
204
+ if (originMtime) {
205
+ const ms = new Date(originMtime).getTime();
206
+ if (ms > 0 && !isNaN(ms))
207
+ return originMtime;
208
+ }
209
+ return new Date().toISOString();
210
+ }
211
+ var MAX_CONTENT_SIZE = 10 * 1024 * 1024;
212
+ function upsertItem(contributor, path, content, originCtime, originMtime) {
213
+ if (Buffer.byteLength(content) > MAX_CONTENT_SIZE) {
214
+ throw new ItemTooLargeError(Buffer.byteLength(content));
215
+ }
113
216
  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 (?, ?, ?, ?, ?, ?)
217
+ const createdAt = validateOriginCtime(originCtime, originMtime);
218
+ const modifiedAt = originMtime || now;
219
+ getDb().prepare(`INSERT INTO items (contributor, path, content, created_at, modified_at)
220
+ VALUES (?, ?, ?, ?, ?)
116
221
  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);
222
+ content = excluded.content,
223
+ modified_at = excluded.modified_at`).run(contributor, path, content, createdAt, modifiedAt);
224
+ return getItem(contributor, path);
120
225
  }
121
- function getFileMetadata(contributor, path) {
122
- return getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path = ?").get(contributor, path);
226
+ function getItem(contributor, path) {
227
+ return getDb().prepare("SELECT contributor, path, content, created_at, modified_at FROM items WHERE contributor = ? AND path = ?").get(contributor, path);
123
228
  }
124
- function listFileMetadata(contributor, prefix) {
125
- const map = new Map;
229
+ function listItems(contributor, prefix) {
126
230
  let rows;
127
231
  if (prefix) {
128
- rows = getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path LIKE ?").all(contributor, prefix + "%");
232
+ rows = getDb().prepare(`SELECT path, length(content) as size, created_at, modified_at
233
+ FROM items WHERE contributor = ? AND path LIKE ?
234
+ ORDER BY modified_at DESC`).all(contributor, prefix + "%");
129
235
  } 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);
236
+ rows = getDb().prepare(`SELECT path, length(content) as size, created_at, modified_at
237
+ FROM items WHERE contributor = ?
238
+ ORDER BY modified_at DESC`).all(contributor);
134
239
  }
135
- return map;
240
+ return rows;
136
241
  }
137
- function deleteFileMetadata(contributor, path) {
138
- getDb().prepare("DELETE FROM files WHERE contributor = ? AND path = ?").run(contributor, path);
242
+ function deleteItem(contributor, path) {
243
+ const result = getDb().prepare("DELETE FROM items WHERE contributor = ? AND path = ?").run(contributor, path);
244
+ return result.changes > 0;
245
+ }
246
+ function searchItems(query, contributor, limit = 10) {
247
+ if (contributor) {
248
+ return getDb().prepare(`SELECT i.contributor, i.path,
249
+ snippet(items_fts, 1, '<b>', '</b>', '...', 32) as snippet,
250
+ rank
251
+ FROM items_fts
252
+ JOIN items i ON items_fts.rowid = i.rowid
253
+ WHERE items_fts MATCH ?
254
+ AND i.contributor = ?
255
+ ORDER BY rank
256
+ LIMIT ?`).all(query, contributor, limit);
257
+ }
258
+ return getDb().prepare(`SELECT i.contributor, i.path,
259
+ snippet(items_fts, 1, '<b>', '</b>', '...', 32) as snippet,
260
+ rank
261
+ FROM items_fts
262
+ JOIN items i ON items_fts.rowid = i.rowid
263
+ WHERE items_fts MATCH ?
264
+ ORDER BY rank
265
+ LIMIT ?`).all(query, limit);
266
+ }
267
+ function createActivityEvent(contributor, action, detail) {
268
+ const id = `act_${randomBytes(6).toString("hex")}`;
269
+ const now = new Date().toISOString();
270
+ const detailJson = detail ? JSON.stringify(detail) : null;
271
+ getDb().prepare("INSERT INTO activity (id, contributor, action, detail, created_at) VALUES (?, ?, ?, ?, ?)").run(id, contributor, action, detailJson, now);
272
+ return { id, contributor, action, detail: detailJson, created_at: now };
273
+ }
274
+ function listActivityEvents(opts) {
275
+ const conditions = [];
276
+ const params = [];
277
+ if (opts?.contributor) {
278
+ conditions.push("contributor = ?");
279
+ params.push(opts.contributor);
280
+ }
281
+ if (opts?.action) {
282
+ conditions.push("action = ?");
283
+ params.push(opts.action);
284
+ }
285
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
286
+ const limit = opts?.limit ?? 50;
287
+ const offset = opts?.offset ?? 0;
288
+ params.push(limit, offset);
289
+ return getDb().prepare(`SELECT id, contributor, action, detail, created_at
290
+ FROM activity ${where}
291
+ ORDER BY created_at DESC
292
+ LIMIT ? OFFSET ?`).all(...params);
293
+ }
294
+
295
+ class ItemTooLargeError extends Error {
296
+ size;
297
+ constructor(size) {
298
+ super(`Content too large: ${size} bytes (max ${MAX_CONTENT_SIZE})`);
299
+ this.name = "ItemTooLargeError";
300
+ this.size = size;
301
+ }
139
302
  }
140
303
 
141
304
  // ../node_modules/.bun/hono@4.11.9/node_modules/hono/dist/compose.js
@@ -1662,9 +1825,9 @@ import { readFileSync } from "fs";
1662
1825
  import { resolve } from "path";
1663
1826
 
1664
1827
  // src/auth.ts
1665
- import { createHash, randomBytes } from "crypto";
1828
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
1666
1829
  function generateToken() {
1667
- return `sv_${randomBytes(16).toString("hex")}`;
1830
+ return `sv_${randomBytes2(16).toString("hex")}`;
1668
1831
  }
1669
1832
  function hashToken(raw2) {
1670
1833
  return createHash("sha256").update(raw2).digest("hex");
@@ -1700,140 +1863,6 @@ function getAuthCtx(c) {
1700
1863
  return c.get("authCtx");
1701
1864
  }
1702
1865
 
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
1866
  // src/sse.ts
1838
1867
  var clients = new Set;
1839
1868
  function addClient(controller) {
@@ -1857,181 +1886,161 @@ data: ${JSON.stringify(data)}
1857
1886
  }
1858
1887
  }
1859
1888
 
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);
1889
+ // src/diff.ts
1890
+ var DIFF_MAX_BYTES = 5000;
1891
+ function computeDiff(oldText, newText) {
1892
+ if (oldText === newText)
1893
+ return null;
1894
+ if (oldText === "")
1895
+ return null;
1896
+ const oldLines = oldText.split(`
1897
+ `);
1898
+ const newLines = newText.split(`
1899
+ `);
1900
+ const editScript = myersDiff(oldLines, newLines);
1901
+ const hunks = buildHunks(editScript, oldLines, newLines, 3);
1902
+ let diff = "";
1903
+ let truncated = false;
1904
+ for (const hunk of hunks) {
1905
+ const header = `@@ -${hunk.oldStart},${hunk.oldCount}` + ` +${hunk.newStart},${hunk.newCount} @@
1906
+ `;
1907
+ diff += header;
1908
+ for (const line of hunk.lines) {
1909
+ diff += line + `
1910
+ `;
1898
1911
  }
1899
- } catch (e) {
1900
- console.error("QMD update error:", e);
1901
- } finally {
1902
- updateInFlight = false;
1903
- if (updateQueued) {
1904
- updateQueued = false;
1905
- runUpdate();
1912
+ if (diff.length > DIFF_MAX_BYTES) {
1913
+ diff = diff.slice(0, DIFF_MAX_BYTES);
1914
+ truncated = true;
1915
+ break;
1906
1916
  }
1907
1917
  }
1918
+ return { diff, truncated };
1908
1919
  }
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 = "";
1920
+ function myersDiff(a, b) {
1921
+ const n = a.length;
1922
+ const m = b.length;
1923
+ const max = n + m;
1924
+ const vSize = 2 * max + 1;
1925
+ const v = new Int32Array(vSize);
1926
+ v.fill(-1);
1927
+ const offset = max;
1928
+ v[offset + 1] = 0;
1929
+ const trace = [];
1930
+ outer:
1931
+ for (let d = 0;d <= max; d++) {
1932
+ trace.push(v.slice());
1933
+ for (let k = -d;k <= d; k += 2) {
1934
+ let x2;
1935
+ if (k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1]) {
1936
+ x2 = v[offset + k + 1];
1937
+ } else {
1938
+ x2 = v[offset + k - 1] + 1;
1939
+ }
1940
+ let y2 = x2 - k;
1941
+ while (x2 < n && y2 < m && a[x2] === b[y2]) {
1942
+ x2++;
1943
+ y2++;
1944
+ }
1945
+ v[offset + k] = x2;
1946
+ if (x2 >= n && y2 >= m)
1947
+ break outer;
1967
1948
  }
1949
+ }
1950
+ const edits = [];
1951
+ let x = n;
1952
+ let y = m;
1953
+ for (let d = trace.length - 1;d >= 0; d--) {
1954
+ const tv = trace[d];
1955
+ const k = x - y;
1956
+ let prevK;
1957
+ if (k === -d || k !== d && tv[offset + k - 1] < tv[offset + k + 1]) {
1958
+ prevK = k + 1;
1968
1959
  } else {
1969
- current += ch;
1960
+ prevK = k - 1;
1961
+ }
1962
+ const prevX = tv[offset + prevK];
1963
+ const prevY = prevX - prevK;
1964
+ while (x > prevX && y > prevY) {
1965
+ x--;
1966
+ y--;
1967
+ edits.push({ op: 0 /* Equal */, oldIdx: x, newIdx: y });
1968
+ }
1969
+ if (d > 0) {
1970
+ if (x === prevX) {
1971
+ edits.push({ op: 1 /* Insert */, oldIdx: x, newIdx: prevY });
1972
+ y--;
1973
+ } else {
1974
+ edits.push({ op: 2 /* Delete */, oldIdx: prevX, newIdx: y });
1975
+ x--;
1976
+ }
1970
1977
  }
1971
1978
  }
1972
- if (current.length > 0) {
1973
- args.push(current);
1974
- }
1975
- return args;
1979
+ edits.reverse();
1980
+ return edits;
1976
1981
  }
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");
1982
+ function buildHunks(edits, oldLines, newLines, context) {
1983
+ const hunks = [];
1984
+ let i = 0;
1985
+ while (i < edits.length) {
1986
+ while (i < edits.length && edits[i].op === 0 /* Equal */)
1987
+ i++;
1988
+ if (i >= edits.length)
1989
+ break;
1990
+ let start = i;
1991
+ for (let c = 0;c < context && start > 0; c++)
1992
+ start--;
1993
+ let end = i;
1994
+ while (end < edits.length) {
1995
+ if (edits[end].op !== 0 /* Equal */) {
1996
+ end++;
1997
+ continue;
1998
+ }
1999
+ let run = 0;
2000
+ let j = end;
2001
+ while (j < edits.length && edits[j].op === 0 /* Equal */) {
2002
+ run++;
2003
+ j++;
2004
+ }
2005
+ if (j >= edits.length || run > context * 2) {
2006
+ end = Math.min(end + context, edits.length);
2007
+ break;
2008
+ }
2009
+ end = j;
2010
+ }
2011
+ const hunkEdits = edits.slice(start, end);
2012
+ const firstEdit = hunkEdits[0];
2013
+ let oldStart = firstEdit.oldIdx + 1;
2014
+ let newStart = firstEdit.newIdx + 1;
2015
+ let oldCount = 0;
2016
+ let newCount = 0;
2017
+ const lines = [];
2018
+ for (const e of hunkEdits) {
2019
+ if (e.op === 0 /* Equal */) {
2020
+ lines.push(" " + oldLines[e.oldIdx]);
2021
+ oldCount++;
2022
+ newCount++;
2023
+ } else if (e.op === 2 /* Delete */) {
2024
+ lines.push("-" + oldLines[e.oldIdx]);
2025
+ oldCount++;
2026
+ } else {
2027
+ lines.push("+" + newLines[e.newIdx]);
2028
+ newCount++;
2029
+ }
1989
2030
  }
2031
+ hunks.push({ oldStart, oldCount, newStart, newCount, lines });
2032
+ i = end;
1990
2033
  }
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
- }
2034
+ return hunks;
2030
2035
  }
2031
2036
 
2032
2037
  // src/routes.ts
2033
2038
  var uiPath = resolve(import.meta.dirname, "index.html");
2034
2039
  var isDev = true;
2040
+ function logActivity(contributor, action, detail) {
2041
+ const event = createActivityEvent(contributor, action, detail);
2042
+ broadcast("activity", event);
2043
+ }
2035
2044
  var uiHtmlCached = readFileSync(uiPath, "utf-8");
2036
2045
  function extractFileInfo(reqPath) {
2037
2046
  const raw2 = reqPath.replace("/v1/files/", "");
@@ -2049,7 +2058,7 @@ function extractFileInfo(reqPath) {
2049
2058
  filePath: decoded.slice(slashIdx + 1)
2050
2059
  };
2051
2060
  }
2052
- function createApp(storageRoot) {
2061
+ function createApp() {
2053
2062
  const app = new Hono2;
2054
2063
  app.get("/", (c) => {
2055
2064
  return c.html(isDev ? readFileSync(uiPath, "utf-8") : uiHtmlCached);
@@ -2084,13 +2093,14 @@ function createApp(storageRoot) {
2084
2093
  return c.json({ error: "A contributor with that username already exists" }, 409);
2085
2094
  }
2086
2095
  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
2096
  const rawToken = generateToken();
2090
2097
  createApiKey(hashToken(rawToken), `${username}-default`, contributor.username);
2091
2098
  if (!isFirstUser && body.invite) {
2092
2099
  markInviteUsed(body.invite, contributor.username);
2093
2100
  }
2101
+ logActivity(contributor.username, "contributor_created", {
2102
+ username: contributor.username
2103
+ });
2094
2104
  return c.json({
2095
2105
  contributor: {
2096
2106
  username: contributor.username,
@@ -2110,10 +2120,13 @@ function createApp(storageRoot) {
2110
2120
  });
2111
2121
  authed.post("/v1/invites", (c) => {
2112
2122
  const { contributor } = getAuthCtx(c);
2113
- if (!contributor.is_operator) {
2114
- return c.json({ error: "Only the operator can generate invite codes" }, 403);
2123
+ if (!contributor.is_admin) {
2124
+ return c.json({ error: "Only the admin can generate invite codes" }, 403);
2115
2125
  }
2116
2126
  const invite = createInvite(contributor.username);
2127
+ logActivity(contributor.username, "invite_created", {
2128
+ invite: invite.id
2129
+ });
2117
2130
  return c.json({
2118
2131
  invite: invite.id,
2119
2132
  createdAt: invite.created_at
@@ -2128,27 +2141,23 @@ function createApp(storageRoot) {
2128
2141
  }))
2129
2142
  });
2130
2143
  });
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);
2144
+ authed.delete("/v1/contributors/:username", (c) => {
2145
+ const { contributor } = getAuthCtx(c);
2146
+ const target = c.req.param("username");
2147
+ if (!contributor.is_admin) {
2148
+ return c.json({ error: "Only the admin can delete contributors" }, 403);
2135
2149
  }
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;
2150
+ if (target === contributor.username) {
2151
+ return c.json({ error: "Cannot delete yourself" }, 400);
2151
2152
  }
2153
+ const found = deleteContributor(target);
2154
+ if (!found) {
2155
+ return c.json({ error: "Contributor not found" }, 404);
2156
+ }
2157
+ logActivity(contributor.username, "contributor_deleted", {
2158
+ username: target
2159
+ });
2160
+ return c.body(null, 204);
2152
2161
  });
2153
2162
  authed.put("/v1/files/*", async (c) => {
2154
2163
  const { contributor } = getAuthCtx(c);
@@ -2170,31 +2179,40 @@ function createApp(storageRoot) {
2170
2179
  const now = new Date().toISOString();
2171
2180
  const originCtime = c.req.header("X-Origin-Ctime") || now;
2172
2181
  const originMtime = c.req.header("X-Origin-Mtime") || now;
2182
+ const existing = getItem(parsed.username, parsed.filePath);
2173
2183
  try {
2174
- const result = await writeFileAtomic(storageRoot, parsed.username, parsed.filePath, content);
2175
- const meta = upsertFileMetadata(parsed.username, parsed.filePath, originCtime, originMtime);
2184
+ const item = upsertItem(parsed.username, parsed.filePath, content, originCtime, originMtime);
2185
+ const detail = {
2186
+ path: item.path,
2187
+ size: Buffer.byteLength(item.content)
2188
+ };
2189
+ const diffResult = computeDiff(existing?.content ?? "", content);
2190
+ if (diffResult) {
2191
+ detail.diff = diffResult.diff;
2192
+ if (diffResult.truncated)
2193
+ detail.diff_truncated = true;
2194
+ }
2195
+ logActivity(contributor.username, "file_upserted", detail);
2176
2196
  broadcast("file_updated", {
2177
2197
  contributor: parsed.username,
2178
- path: result.path,
2179
- size: result.size,
2180
- modifiedAt: result.modifiedAt
2198
+ path: item.path,
2199
+ size: Buffer.byteLength(item.content),
2200
+ modifiedAt: item.modified_at
2181
2201
  });
2182
- triggerUpdate();
2183
2202
  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
2203
+ path: item.path,
2204
+ size: Buffer.byteLength(item.content),
2205
+ createdAt: item.created_at,
2206
+ modifiedAt: item.modified_at
2189
2207
  });
2190
2208
  } catch (e) {
2191
- if (e instanceof FileTooLargeError) {
2209
+ if (e instanceof ItemTooLargeError) {
2192
2210
  return c.json({ error: e.message }, 413);
2193
2211
  }
2194
2212
  throw e;
2195
2213
  }
2196
2214
  });
2197
- authed.delete("/v1/files/*", async (c) => {
2215
+ authed.delete("/v1/files/*", (c) => {
2198
2216
  const { contributor } = getAuthCtx(c);
2199
2217
  const parsed = extractFileInfo(c.req.path);
2200
2218
  if (!parsed) {
@@ -2207,23 +2225,20 @@ function createApp(storageRoot) {
2207
2225
  if (pathError) {
2208
2226
  return c.json({ error: pathError }, 400);
2209
2227
  }
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;
2228
+ const found = deleteItem(parsed.username, parsed.filePath);
2229
+ if (!found) {
2230
+ return c.json({ error: "File not found" }, 404);
2224
2231
  }
2232
+ logActivity(contributor.username, "file_deleted", {
2233
+ path: parsed.filePath
2234
+ });
2235
+ broadcast("file_deleted", {
2236
+ contributor: parsed.username,
2237
+ path: parsed.filePath
2238
+ });
2239
+ return c.body(null, 204);
2225
2240
  });
2226
- authed.get("/v1/files", async (c) => {
2241
+ authed.get("/v1/files", (c) => {
2227
2242
  const prefix = c.req.query("prefix") || "";
2228
2243
  if (!prefix) {
2229
2244
  return c.json({ error: "prefix query parameter is required" }, 400);
@@ -2234,20 +2249,33 @@ function createApp(storageRoot) {
2234
2249
  if (!getContributor(username)) {
2235
2250
  return c.json({ error: "Contributor not found" }, 404);
2236
2251
  }
2237
- const files = await listFiles(storageRoot, username, subPrefix);
2238
- const metaMap = listFileMetadata(username, subPrefix);
2252
+ const items = listItems(username, subPrefix);
2239
2253
  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
- })
2254
+ files: items.map((f) => ({
2255
+ path: `${username}/${f.path}`,
2256
+ size: f.size,
2257
+ createdAt: f.created_at,
2258
+ modifiedAt: f.modified_at
2259
+ }))
2260
+ });
2261
+ });
2262
+ authed.get("/v1/files/*", (c) => {
2263
+ const parsed = extractFileInfo(c.req.path);
2264
+ if (!parsed) {
2265
+ return c.json({ error: "Invalid file path" }, 400);
2266
+ }
2267
+ const item = getItem(parsed.username, parsed.filePath);
2268
+ if (!item) {
2269
+ return c.json({ error: "File not found" }, 404);
2270
+ }
2271
+ return new Response(item.content, {
2272
+ status: 200,
2273
+ headers: {
2274
+ "Content-Type": "text/markdown; charset=utf-8",
2275
+ "X-Created-At": item.created_at,
2276
+ "X-Modified-At": item.modified_at,
2277
+ "X-Size": String(Buffer.byteLength(item.content))
2278
+ }
2251
2279
  });
2252
2280
  });
2253
2281
  authed.get("/v1/events", (c) => {
@@ -2285,52 +2313,46 @@ data: {}
2285
2313
  }
2286
2314
  });
2287
2315
  });
2288
- authed.get("/v1/search", async (c) => {
2316
+ authed.get("/v1/search", (c) => {
2289
2317
  const q = c.req.query("q");
2290
2318
  if (!q) {
2291
2319
  return c.json({ error: "q parameter is required" }, 400);
2292
2320
  }
2293
2321
  const contributorParam = c.req.query("contributor") || undefined;
2294
2322
  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;
2323
+ if (contributorParam && !getContributor(contributorParam)) {
2324
+ return c.json({ error: "Contributor not found" }, 404);
2302
2325
  }
2303
- const results = await search(q, { collection: collectionName, limit });
2326
+ const results = searchItems(q, contributorParam, limit);
2304
2327
  return c.json({ results });
2305
2328
  });
2329
+ authed.get("/v1/activity", (c) => {
2330
+ const contributor = c.req.query("contributor") || undefined;
2331
+ const action = c.req.query("action") || undefined;
2332
+ const limit = c.req.query("limit") ? parseInt(c.req.query("limit"), 10) : undefined;
2333
+ const offset = c.req.query("offset") ? parseInt(c.req.query("offset"), 10) : undefined;
2334
+ const events = listActivityEvents({
2335
+ contributor,
2336
+ action,
2337
+ limit,
2338
+ offset
2339
+ });
2340
+ return c.json({ events });
2341
+ });
2306
2342
  app.route("/", authed);
2307
2343
  return app;
2308
2344
  }
2309
2345
 
2310
2346
  // src/index.ts
2311
2347
  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 });
2348
+ var DATA_DIR = process.env.DATA_DIR || join(homedir(), ".seedvault", "data");
2349
+ var dbPath = join(DATA_DIR, "seedvault.db");
2350
+ await mkdir(DATA_DIR, { recursive: true });
2317
2351
  initDb(dbPath);
2318
- var app = createApp(storageRoot);
2352
+ var app = createApp();
2319
2353
  console.log(`Seedvault server starting on port ${PORT}`);
2320
2354
  console.log(` Data dir: ${DATA_DIR}`);
2321
2355
  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
2356
  var server = Bun.serve({
2335
2357
  port: PORT,
2336
2358
  fetch: app.fetch