@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.
- package/dist/index.html +617 -59
- package/dist/server.js +445 -423
- 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
|
|
3
|
+
import { join } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
import { mkdir
|
|
5
|
+
import { mkdir } from "fs/promises";
|
|
6
6
|
|
|
7
7
|
// src/db.ts
|
|
8
8
|
import { Database } from "bun:sqlite";
|
|
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
|
-
|
|
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
|
|
44
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
45
45
|
contributor TEXT NOT NULL,
|
|
46
46
|
path TEXT NOT NULL,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
server_modified_at TEXT NOT NULL,
|
|
47
|
+
content TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
modified_at TEXT NOT NULL,
|
|
51
50
|
PRIMARY KEY (contributor, path),
|
|
52
51
|
FOREIGN KEY (contributor) REFERENCES contributors(username)
|
|
53
52
|
);
|
|
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
|
|
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,
|
|
72
|
-
return { username,
|
|
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,
|
|
137
|
+
const row = getDb().prepare("SELECT username, is_admin, created_at FROM contributors WHERE username = ?").get(username);
|
|
76
138
|
if (row)
|
|
77
|
-
row.
|
|
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,
|
|
82
|
-
return rows.map((r) => ({ ...r,
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
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
|
|
122
|
-
return getDb().prepare("SELECT
|
|
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
|
|
125
|
-
const map = new Map;
|
|
229
|
+
function listItems(contributor, prefix) {
|
|
126
230
|
let rows;
|
|
127
231
|
if (prefix) {
|
|
128
|
-
rows = getDb().prepare(
|
|
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(
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
240
|
+
return rows;
|
|
136
241
|
}
|
|
137
|
-
function
|
|
138
|
-
getDb().prepare("DELETE FROM
|
|
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_${
|
|
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/
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
const
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1910
|
-
const
|
|
1911
|
-
const
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1973
|
-
|
|
1974
|
-
}
|
|
1975
|
-
return args;
|
|
1979
|
+
edits.reverse();
|
|
1980
|
+
return edits;
|
|
1976
1981
|
}
|
|
1977
|
-
|
|
1978
|
-
const
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
2114
|
-
return c.json({ error: "Only the
|
|
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.
|
|
2132
|
-
const
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
return new Response(result.stdout, {
|
|
2139
|
-
status: 200,
|
|
2140
|
-
headers: {
|
|
2141
|
-
"Content-Type": "text/plain; charset=utf-8",
|
|
2142
|
-
"X-Exit-Code": String(result.exitCode),
|
|
2143
|
-
"X-Stderr": encodeURIComponent(result.stderr)
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
} catch (e) {
|
|
2147
|
-
if (e instanceof ShellValidationError) {
|
|
2148
|
-
return c.json({ error: e.message }, 400);
|
|
2149
|
-
}
|
|
2150
|
-
throw e;
|
|
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
|
|
2175
|
-
const
|
|
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:
|
|
2179
|
-
size:
|
|
2180
|
-
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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
|
|
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/*",
|
|
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
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
broadcast("file_deleted", {
|
|
2214
|
-
contributor: parsed.username,
|
|
2215
|
-
path: parsed.filePath
|
|
2216
|
-
});
|
|
2217
|
-
triggerUpdate();
|
|
2218
|
-
return c.body(null, 204);
|
|
2219
|
-
} catch (e) {
|
|
2220
|
-
if (e instanceof FileNotFoundError) {
|
|
2221
|
-
return c.json({ error: "File not found" }, 404);
|
|
2222
|
-
}
|
|
2223
|
-
throw e;
|
|
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",
|
|
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
|
|
2238
|
-
const metaMap = listFileMetadata(username, subPrefix);
|
|
2252
|
+
const items = listItems(username, subPrefix);
|
|
2239
2253
|
return c.json({
|
|
2240
|
-
files:
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
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",
|
|
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
|
-
|
|
2296
|
-
|
|
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 =
|
|
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 ||
|
|
2313
|
-
var dbPath =
|
|
2314
|
-
|
|
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(
|
|
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
|