dsh-taskboard 0.6.7 → 0.7.1

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.
@@ -0,0 +1,139 @@
1
+ import { join } from "node:path";
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/host/assets.ts
5
+ /** Durable, content-addressed image attachments for task descriptions/comments. */
6
+ const MAX_ASSET_BYTES = 5 * 1024 * 1024;
7
+ const MAX_ASSET_STORE_BYTES = 200 * 1024 * 1024;
8
+ const ORPHAN_GRACE_MS = 1440 * 60 * 1e3;
9
+ const ASSET_NAME_RE = /^([a-f0-9]{64})\.(png|jpg|gif|webp)$/;
10
+ /** Detect supported images from magic bytes; request MIME and filenames are not trusted. */
11
+ function detectImage(bytes) {
12
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) return {
13
+ extension: "png",
14
+ mime: "image/png"
15
+ };
16
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return {
17
+ extension: "jpg",
18
+ mime: "image/jpeg"
19
+ };
20
+ if (bytes.length >= 6) {
21
+ const head = Buffer.from(bytes.subarray(0, 6)).toString("ascii");
22
+ if (head === "GIF87a" || head === "GIF89a") return {
23
+ extension: "gif",
24
+ mime: "image/gif"
25
+ };
26
+ }
27
+ if (bytes.length >= 12 && Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "RIFF" && Buffer.from(bytes.subarray(8, 12)).toString("ascii") === "WEBP") return {
28
+ extension: "webp",
29
+ mime: "image/webp"
30
+ };
31
+ }
32
+ /** Files live outside the ledger so state/SSE/tool payloads only carry short Markdown URLs. */
33
+ var AssetStore = class {
34
+ now;
35
+ storageQueue;
36
+ queue = Promise.resolve();
37
+ root;
38
+ constructor(root, now = () => Date.now(), storageQueue) {
39
+ this.now = now;
40
+ this.storageQueue = storageQueue;
41
+ this.root = root;
42
+ }
43
+ /** Current absolute attachment-directory path. */
44
+ location() {
45
+ return this.root;
46
+ }
47
+ /** Switch reads and future writes after the coordinator copied the directory. */
48
+ setLocation(root) {
49
+ this.root = root;
50
+ }
51
+ async put(bytes, declaredMime) {
52
+ const run = () => this.putSerial(bytes, declaredMime);
53
+ if (this.storageQueue !== void 0) return this.storageQueue.run(run);
54
+ return this.queue = this.queue.then(run, run);
55
+ }
56
+ async putSerial(bytes, declaredMime) {
57
+ if (bytes.length === 0 || bytes.length > 5242880) throw new Error(`image must be 1..${MAX_ASSET_BYTES} bytes`);
58
+ const kind = detectImage(bytes);
59
+ if (kind === void 0) throw new Error("unsupported image; use PNG, JPEG, GIF, or WebP");
60
+ if (declaredMime !== void 0 && declaredMime.toLowerCase() !== kind.mime) throw new Error(`image content does not match ${declaredMime}`);
61
+ const id = createHash("sha256").update(bytes).digest("hex");
62
+ const name = `${id}.${kind.extension}`;
63
+ await mkdir(this.root, { recursive: true });
64
+ try {
65
+ const current = await stat(join(this.root, name));
66
+ if (current.isFile()) return {
67
+ id,
68
+ name,
69
+ size: current.size,
70
+ url: `/dsh-taskboard/assets/${name}`,
71
+ ...kind
72
+ };
73
+ } catch {}
74
+ let total = 0;
75
+ for (const entry of await readdir(this.root, { withFileTypes: true })) {
76
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name)) continue;
77
+ try {
78
+ total += (await stat(join(this.root, entry.name))).size;
79
+ } catch {}
80
+ }
81
+ if (total + bytes.length > 209715200) throw new Error("image store quota exceeded");
82
+ try {
83
+ await writeFile(join(this.root, name), bytes, { flag: "wx" });
84
+ } catch (error) {
85
+ if (error.code !== "EEXIST") throw error;
86
+ }
87
+ return {
88
+ id,
89
+ name,
90
+ size: bytes.length,
91
+ url: `/dsh-taskboard/assets/${name}`,
92
+ ...kind
93
+ };
94
+ }
95
+ async read(name) {
96
+ const run = async () => {
97
+ const match = ASSET_NAME_RE.exec(name);
98
+ if (match === null) return void 0;
99
+ const mime = match[2] === "png" ? "image/png" : match[2] === "jpg" ? "image/jpeg" : match[2] === "gif" ? "image/gif" : "image/webp";
100
+ try {
101
+ return {
102
+ bytes: await readFile(join(this.root, name)),
103
+ mime
104
+ };
105
+ } catch {
106
+ return;
107
+ }
108
+ };
109
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
110
+ }
111
+ /** Remove abandoned draft uploads after a grace period; referenced files always survive. */
112
+ async cleanup(referencedContent) {
113
+ const run = async () => {
114
+ let entries;
115
+ try {
116
+ entries = await readdir(this.root, { withFileTypes: true });
117
+ } catch {
118
+ return 0;
119
+ }
120
+ let removed = 0;
121
+ for (const entry of entries) {
122
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name) || referencedContent.includes(entry.name)) continue;
123
+ const path = join(this.root, entry.name);
124
+ try {
125
+ const info = await stat(path);
126
+ if (this.now() - info.mtimeMs < 864e5) continue;
127
+ await rm(path, { force: true });
128
+ removed += 1;
129
+ } catch {}
130
+ }
131
+ return removed;
132
+ };
133
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
134
+ }
135
+ };
136
+ //#endregion
137
+ export { AssetStore, MAX_ASSET_BYTES, MAX_ASSET_STORE_BYTES, ORPHAN_GRACE_MS, detectImage };
138
+
139
+ //# sourceMappingURL=assets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assets.js","names":[],"sources":["../../src/host/assets.ts"],"sourcesContent":["/** Durable, content-addressed image attachments for task descriptions/comments. */\nimport { createHash } from 'node:crypto'\nimport { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport type { StorageQueue } from './storage-queue.ts'\n\nexport const MAX_ASSET_BYTES = 5 * 1024 * 1024\nexport const MAX_ASSET_STORE_BYTES = 200 * 1024 * 1024\nexport const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000\n\nexport type ImageKind = { extension: 'png' | 'jpg' | 'gif' | 'webp'; mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' }\nexport type StoredAsset = ImageKind & { id: string; name: string; size: number; url: string }\n\nconst ASSET_NAME_RE = /^([a-f0-9]{64})\\.(png|jpg|gif|webp)$/\n\n/** Detect supported images from magic bytes; request MIME and filenames are not trusted. */\nexport function detectImage(bytes: Uint8Array): ImageKind | undefined {\n if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47\n && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {\n return { extension: 'png', mime: 'image/png' }\n }\n if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {\n return { extension: 'jpg', mime: 'image/jpeg' }\n }\n if (bytes.length >= 6) {\n const head = Buffer.from(bytes.subarray(0, 6)).toString('ascii')\n if (head === 'GIF87a' || head === 'GIF89a') return { extension: 'gif', mime: 'image/gif' }\n }\n if (bytes.length >= 12 && Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF'\n && Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') {\n return { extension: 'webp', mime: 'image/webp' }\n }\n return undefined\n}\n\n/** Files live outside the ledger so state/SSE/tool payloads only carry short Markdown URLs. */\nexport class AssetStore {\n private queue: Promise<unknown> = Promise.resolve()\n private root: string\n\n constructor(root: string, private readonly now: () => number = () => Date.now(), private readonly storageQueue?: StorageQueue) { this.root = root }\n\n /** Current absolute attachment-directory path. */\n location(): string { return this.root }\n\n /** Switch reads and future writes after the coordinator copied the directory. */\n setLocation(root: string): void { this.root = root }\n\n async put(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {\n const run = () => this.putSerial(bytes, declaredMime)\n if (this.storageQueue !== undefined) return this.storageQueue.run(run)\n return (this.queue = this.queue.then(run, run)) as Promise<StoredAsset>\n }\n\n private async putSerial(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {\n if (bytes.length === 0 || bytes.length > MAX_ASSET_BYTES) {\n throw new Error(`image must be 1..${MAX_ASSET_BYTES} bytes`)\n }\n const kind = detectImage(bytes)\n if (kind === undefined) throw new Error('unsupported image; use PNG, JPEG, GIF, or WebP')\n if (declaredMime !== undefined && declaredMime.toLowerCase() !== kind.mime) {\n throw new Error(`image content does not match ${declaredMime}`)\n }\n const id = createHash('sha256').update(bytes).digest('hex')\n const name = `${id}.${kind.extension}`\n await mkdir(this.root, { recursive: true })\n try {\n const current = await stat(join(this.root, name))\n if (current.isFile()) return { id, name, size: current.size, url: `/dsh-taskboard/assets/${name}`, ...kind }\n } catch { /* new content */ }\n\n let total = 0\n for (const entry of await readdir(this.root, { withFileTypes: true })) {\n if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name)) continue\n try { total += (await stat(join(this.root, entry.name))).size } catch { /* concurrent cleanup */ }\n }\n if (total + bytes.length > MAX_ASSET_STORE_BYTES) throw new Error('image store quota exceeded')\n try {\n await writeFile(join(this.root, name), bytes, { flag: 'wx' })\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error\n }\n return { id, name, size: bytes.length, url: `/dsh-taskboard/assets/${name}`, ...kind }\n }\n\n async read(name: string): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> {\n const run = async (): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> => {\n const match = ASSET_NAME_RE.exec(name)\n if (match === null) return undefined\n const mime = match[2] === 'png' ? 'image/png'\n : match[2] === 'jpg' ? 'image/jpeg'\n : match[2] === 'gif' ? 'image/gif' : 'image/webp'\n try {\n return { bytes: await readFile(join(this.root, name)), mime }\n } catch { return undefined }\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /** Remove abandoned draft uploads after a grace period; referenced files always survive. */\n async cleanup(referencedContent: string): Promise<number> {\n const run = async (): Promise<number> => {\n let entries\n try { entries = await readdir(this.root, { withFileTypes: true }) } catch { return 0 }\n let removed = 0\n for (const entry of entries) {\n if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name) || referencedContent.includes(entry.name)) continue\n const path = join(this.root, entry.name)\n try {\n const info = await stat(path)\n if (this.now() - info.mtimeMs < ORPHAN_GRACE_MS) continue\n await rm(path, { force: true })\n removed += 1\n } catch { /* best effort */ }\n }\n return removed\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n}\n"],"mappings":"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"}
@@ -4,6 +4,7 @@ import { removeMirror, repoMainPath } from "./isolation.js";
4
4
  import { createRepoScanner } from "./repos.js";
5
5
  import { archiveTaskSessions } from "./archive-sessions.js";
6
6
  import { activeHostLocale } from "./locale.js";
7
+ import { MAX_ASSET_BYTES } from "./assets.js";
7
8
  import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
8
9
  import { ERR, ToolError } from "./tools.js";
9
10
  import { join, resolve, sep } from "node:path";
@@ -17,6 +18,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
17
18
  const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`);
18
19
  const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`);
19
20
  const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`);
21
+ const ASSET_RE = new RegExp(`^${ROUTE_PREFIX}/assets/([a-f0-9]{64}\\.(?:png|jpg|gif|webp))$`);
20
22
  /** How long a workspace git-detection result stays cached (fail-soft). */
21
23
  const GIT_DETECT_TTL_MS = 6e4;
22
24
  /** Validate a template's task spec (routes-side, unknown → invalid_input). */
@@ -103,6 +105,18 @@ async function readBody(req) {
103
105
  return null;
104
106
  }
105
107
  }
108
+ /** Read one bounded binary upload without ever buffering beyond the file cap. */
109
+ async function readBytes(req, limit) {
110
+ const chunks = [];
111
+ let total = 0;
112
+ for await (const chunk of req) {
113
+ const bytes = chunk;
114
+ total += bytes.length;
115
+ if (total > limit) throw new Error("body too large");
116
+ chunks.push(bytes);
117
+ }
118
+ return Buffer.concat(chunks);
119
+ }
106
120
  /** String field accessor (null when absent/not a string). */
107
121
  function str(body, key) {
108
122
  const v = body[key];
@@ -287,6 +301,36 @@ function registerTaskboardRoutes(ctx, options) {
287
301
  const url = new URL(req.url ?? "/", "http://x");
288
302
  const pathname = url.pathname;
289
303
  if (req.method === "GET") {
304
+ if (pathname === `/dsh-taskboard/storage`) {
305
+ if (options.storage === void 0) {
306
+ res.writeHead(501);
307
+ res.end();
308
+ return;
309
+ }
310
+ json(res, {
311
+ ok: true,
312
+ value: await options.storage.status()
313
+ });
314
+ return;
315
+ }
316
+ await options.ready?.();
317
+ const assetMatch = pathname.match(ASSET_RE);
318
+ if (assetMatch !== null) {
319
+ const asset = await options.assets?.read(assetMatch[1]);
320
+ if (asset === void 0) {
321
+ res.writeHead(404);
322
+ res.end();
323
+ return;
324
+ }
325
+ res.writeHead(200, {
326
+ "content-type": asset.mime,
327
+ "content-length": asset.bytes.length,
328
+ "cache-control": "public, max-age=31536000, immutable",
329
+ "x-content-type-options": "nosniff"
330
+ });
331
+ res.end(asset.bytes);
332
+ return;
333
+ }
290
334
  if (pathname === `/dsh-taskboard/state`) {
291
335
  await store.load();
292
336
  json(res, {
@@ -435,6 +479,31 @@ function registerTaskboardRoutes(ctx, options) {
435
479
  res.end();
436
480
  return;
437
481
  }
482
+ if (pathname === `/dsh-taskboard/assets`) {
483
+ await options.ready?.();
484
+ if (options.assets === void 0) {
485
+ json(res, fail("invalid_input", "image attachments unavailable").res, 501);
486
+ return;
487
+ }
488
+ if (req.headers["x-dsh-taskboard-upload"] !== "1") {
489
+ json(res, fail("forbidden", "missing upload header").res, 403);
490
+ return;
491
+ }
492
+ const declaredMime = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
493
+ try {
494
+ const bytes = await readBytes(req, MAX_ASSET_BYTES);
495
+ await options.assets.cleanup(JSON.stringify(store.snapshot()));
496
+ json(res, {
497
+ ok: true,
498
+ value: await options.assets.put(bytes, declaredMime)
499
+ }, 201);
500
+ } catch (error) {
501
+ const message = error instanceof Error ? error.message : String(error);
502
+ const status = message.includes("1..") ? 413 : message.includes("quota") ? 507 : 400;
503
+ json(res, fail("invalid_input", message).res, status);
504
+ }
505
+ return;
506
+ }
438
507
  if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
439
508
  json(res, fail("invalid_input", "content-type must be application/json").res, 415);
440
509
  return;
@@ -450,6 +519,24 @@ function registerTaskboardRoutes(ctx, options) {
450
519
  json(res, fail("invalid_input", "body is not a JSON object").res, 400);
451
520
  return;
452
521
  }
522
+ if (pathname === `/dsh-taskboard/storage/check` || pathname === `/dsh-taskboard/storage/migrate`) {
523
+ if (options.storage === void 0) {
524
+ json(res, fail("invalid_input", "storage configuration unavailable").res, 501);
525
+ return;
526
+ }
527
+ try {
528
+ const directory = str(body, "directory") ?? "";
529
+ json(res, {
530
+ ok: true,
531
+ value: pathname.endsWith("/check") ? await options.storage.check(directory) : await options.storage.migrate(directory)
532
+ });
533
+ } catch (error) {
534
+ const f = fail("invalid_input", error instanceof Error ? error.message : String(error));
535
+ json(res, f.res, f.status);
536
+ }
537
+ return;
538
+ }
539
+ await options.ready?.();
453
540
  if (pathname === `/dsh-taskboard/tasks`) {
454
541
  try {
455
542
  const title = normalizeTitle(str(body, "title") ?? "");