dsh-image-gen 0.10.2 → 0.10.3

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 (55) hide show
  1. package/README.en.md +456 -454
  2. package/README.md +456 -454
  3. package/docs/assets/readme/canvas-edit.webp +0 -0
  4. package/docs/assets/readme/canvas-sketch.webp +0 -0
  5. package/docs/assets/readme/chat-batch.webp +0 -0
  6. package/docs/assets/readme/config.webp +0 -0
  7. package/docs/assets/readme/hero-batch.webp +0 -0
  8. package/docs/assets/readme/hero-canvas.webp +0 -0
  9. package/docs/assets/readme/studio-ref.webp +0 -0
  10. package/docs/assets/readme/studio.webp +0 -0
  11. package/lib/client.js +4871 -3658
  12. package/lib/index.js +2074 -140
  13. package/lib/types/client/browser-image-utils.d.ts +6 -0
  14. package/lib/types/client/datalist-scroll-fix.d.ts +32 -0
  15. package/lib/types/client/gallery-batch-bar.d.ts +8 -0
  16. package/lib/types/client/gallery-card.d.ts +21 -0
  17. package/lib/types/client/gallery-format.d.ts +7 -0
  18. package/lib/types/client/gallery-i18n.d.ts +236 -0
  19. package/lib/types/client/gallery-media.d.ts +46 -0
  20. package/lib/types/client/gallery-merge.d.ts +15 -0
  21. package/lib/types/client/gallery-store.d.ts +21 -2
  22. package/lib/types/client/gallery-thumbs.d.ts +24 -0
  23. package/lib/types/client/gallery-view.d.ts +0 -207
  24. package/lib/types/client/image-result-view.d.ts +24 -0
  25. package/lib/types/client/index.d.ts +1 -20
  26. package/lib/types/client/plugin-i18n.d.ts +375 -0
  27. package/lib/types/client/plugin-style.d.ts +1 -0
  28. package/lib/types/client/studio-helpers.d.ts +63 -0
  29. package/lib/types/client/studio-style.d.ts +1 -1
  30. package/lib/types/client/studio-view.d.ts +0 -7
  31. package/lib/types/client/tl/gallery-image-transfer.d.ts +1 -1
  32. package/lib/types/config.d.ts +12 -0
  33. package/lib/types/import-route.d.ts +11 -0
  34. package/lib/types/multipart.d.ts +29 -0
  35. package/lib/types/openai-compatible.d.ts +8 -0
  36. package/lib/types/request-cache.d.ts +41 -0
  37. package/lib/types/shared.d.ts +23 -0
  38. package/lib/types/storage-stats.d.ts +34 -0
  39. package/lib/types/thumb-route.d.ts +25 -0
  40. package/lib/types/vault-registry.d.ts +36 -0
  41. package/lib/types/vault-route.d.ts +8 -0
  42. package/lib/types/vault-store.d.ts +31 -0
  43. package/lib/types/zip-progress.d.ts +18 -0
  44. package/lib/types/zip-route.d.ts +25 -0
  45. package/lib/types/zip-stream.d.ts +43 -0
  46. package/package.json +139 -139
  47. package/skills/install-dsh-image-gen/SKILL.md +22 -22
  48. package/lib/types/client/tl/tl-trace-bridge.d.ts +0 -55
  49. package/lib/types/client/tl/trace-playback.d.ts +0 -101
  50. package/lib/types/trace-drawing.d.ts +0 -102
  51. package/lib/types/trace-route.d.ts +0 -12
  52. package/lib/types/trace-sketch.d.ts +0 -26
  53. package/lib/types/trace-store.d.ts +0 -23
  54. package/lib/types/vendor/drawai/raster.d.ts +0 -43
  55. package/lib/types/vendor/drawai/trace-image.d.ts +0 -257
package/lib/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Context, Service } from "@deepseek-ai/cordis";
3
3
  import z from "@deepseek-ai/schemastery";
4
- import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
5
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
+ import { crc32 } from "node:zlib";
7
9
  import http from "node:http";
8
10
  //#region node_modules/.pnpm/@deepseek-ai+dsh-scope@0.1._0271d3a82adfe807020938592f267574/node_modules/@deepseek-ai/dsh-scope/lib/index.js
9
11
  /**
@@ -5693,6 +5695,18 @@ function watermarkProfile(config, provider, model) {
5693
5695
  //#region lib/types/shared.js
5694
5696
  /** Browser route used by the generated-image card. */
5695
5697
  const IMAGE_ROUTE = "/plugins/dsh-image-gen/image";
5698
+ /** Decode a GET query value; the caller supplies the reference parser. */
5699
+ function decodeImageRef(value, parse) {
5700
+ try {
5701
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
5702
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
5703
+ const binary = atob(padded);
5704
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
5705
+ return parse(JSON.parse(new TextDecoder().decode(bytes)));
5706
+ } catch {
5707
+ return;
5708
+ }
5709
+ }
5696
5710
  /** Browser route that uploads user-picked images as DSH attachments. */
5697
5711
  const IMPORT_ROUTE = "/plugins/dsh-image-gen/import";
5698
5712
  /** Browser route used for deleting generated images and workspace files. */
@@ -5703,6 +5717,17 @@ const STUDIO_ROUTE = "/plugins/dsh-image-gen/studio";
5703
5717
  const INSPIRATION_ROUTE = "/plugins/dsh-image-gen/inspiration";
5704
5718
  /** Browser route used for saving generated images to workspace on demand. */
5705
5719
  const SAVE_WORKSPACE_ROUTE = "/plugins/dsh-image-gen/save-workspace";
5720
+ /** Browser route that streams a ZIP export of the selected gallery images. */
5721
+ const ZIP_ROUTE = "/plugins/dsh-image-gen/zip";
5722
+ /** Browser route that serves host-rendered gallery thumbnails. */
5723
+ const THUMB_ROUTE = "/plugins/dsh-image-gen/thumb";
5724
+ const PREVIEW_ROUTE = "/plugins/dsh-image-gen/preview";
5725
+ const VAULT_ROUTE = "/plugins/dsh-image-gen/vault";
5726
+ const VAULT_REFS_ROUTE = "/plugins/dsh-image-gen/vault-refs";
5727
+ const VAULT_DELETE_ROUTE = "/plugins/dsh-image-gen/vault-delete";
5728
+ const ZIP_PROGRESS_ROUTE = "/plugins/dsh-image-gen/zip/progress";
5729
+ const ZIP_CANCEL_ROUTE = "/plugins/dsh-image-gen/zip/cancel";
5730
+ const STORAGE_STATS_ROUTE = "/plugins/dsh-image-gen/storage-stats";
5706
5731
  /** Browser route the settings card probes provider connectivity through. */
5707
5732
  const TEST_CONNECTION_ROUTE = "/plugins/dsh-image-gen/test";
5708
5733
  /** Non-secret settings capability probe, available even when the host cannot serve a form. */
@@ -6109,7 +6134,13 @@ const Config = z.object({
6109
6134
  comfyuiTimeoutMs: volatile(z.number().min(1e3).max(36e5).default(DEFAULT_COMFYUI_TIMEOUT_MS)),
6110
6135
  saveToWorkspace: volatile(z.boolean().default(true)),
6111
6136
  workspaceFolder: volatile(z.string().default(DEFAULT_WORKSPACE_FOLDER)),
6112
- showProviderPill: volatile(z.boolean().default(false))
6137
+ showProviderPill: volatile(z.boolean().default(false)),
6138
+ requestCachePrune: volatile(z.boolean().default(true)),
6139
+ requestCacheTtlDays: volatile(z.number().min(1).max(365).default(14)),
6140
+ requestCacheMaxBytes: volatile(z.number().min(0).default(268435456)),
6141
+ requestCacheDir: volatile(z.string().default("")),
6142
+ vaultEnabled: volatile(z.boolean().default(true)),
6143
+ vaultDir: volatile(z.string().default(""))
6113
6144
  });
6114
6145
  /** Resolve exactly one provider profile for a tool call. */
6115
6146
  function resolveProvider(config) {
@@ -6775,24 +6806,24 @@ const MAX_NODE_COUNT = 1e5;
6775
6806
  const MAX_SELECTION_COUNT = 1e3;
6776
6807
  /** Serve one canvas-state push. */
6777
6808
  async function serveCanvasState(req, res, deps) {
6778
- if (req.method !== "POST") return jsonError$6(res, 405, "method-not-allowed");
6779
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$6(res, 415, "json-required");
6809
+ if (req.method !== "POST") return jsonError$11(res, 405, "method-not-allowed");
6810
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$11(res, 415, "json-required");
6780
6811
  const origin = req.headers.origin;
6781
6812
  const host = req.headers.host;
6782
- if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$6(res, 403, "origin-rejected");
6813
+ if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$11(res, 403, "origin-rejected");
6783
6814
  let body;
6784
6815
  try {
6785
- body = JSON.parse(await readBody$5(req, deps.maxBodyBytes));
6816
+ body = JSON.parse(await readBody$8(req, deps.maxBodyBytes));
6786
6817
  } catch (error) {
6787
- return jsonError$6(res, error instanceof Error && error.message === "request-too-large" ? 413 : 400, "invalid-request");
6818
+ return jsonError$11(res, error instanceof Error && error.message === "request-too-large" ? 413 : 400, "invalid-request");
6788
6819
  }
6789
6820
  const push = parseCanvasStatePush(body);
6790
- if (push === void 0) return jsonError$6(res, 400, "invalid-canvas-state");
6821
+ if (push === void 0) return jsonError$11(res, 400, "invalid-canvas-state");
6791
6822
  let selectionImage;
6792
6823
  if (push.selectionImage !== void 0) {
6793
6824
  const decoded = decodeSelectionImage(push.selectionImage);
6794
- if (decoded === void 0) return jsonError$6(res, 400, "invalid-selection-image");
6795
- if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$6(res, 413, "selection-image-too-large");
6825
+ if (decoded === void 0) return jsonError$11(res, 400, "invalid-selection-image");
6826
+ if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$11(res, 413, "selection-image-too-large");
6796
6827
  selectionImage = decoded;
6797
6828
  }
6798
6829
  deps.mirror.apply(push, selectionImage);
@@ -6960,7 +6991,7 @@ function optionalDimension(value) {
6960
6991
  function nonNegativeInteger(value) {
6961
6992
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
6962
6993
  }
6963
- async function readBody$5(req, maxBytes) {
6994
+ async function readBody$8(req, maxBytes) {
6964
6995
  const chunks = [];
6965
6996
  let bytes = 0;
6966
6997
  for await (const chunk of req) {
@@ -6971,7 +7002,7 @@ async function readBody$5(req, maxBytes) {
6971
7002
  }
6972
7003
  return Buffer.concat(chunks).toString("utf8");
6973
7004
  }
6974
- function jsonError$6(res, status, code) {
7005
+ function jsonError$11(res, status, code) {
6975
7006
  res.writeHead(status, {
6976
7007
  "content-type": "application/json",
6977
7008
  "cache-control": "no-store"
@@ -7921,7 +7952,7 @@ async function readBoundedText$2(response, maxBytes, label) {
7921
7952
  //#region lib/types/workspace-save.js
7922
7953
  /** Persist one generated image as a file under the session workspace. */
7923
7954
  /** File extension for each supported image media type. */
7924
- const EXTENSION = {
7955
+ const EXTENSION$1 = {
7925
7956
  "image/png": "png",
7926
7957
  "image/jpeg": "jpg",
7927
7958
  "image/webp": "webp",
@@ -7938,7 +7969,7 @@ const EXTENSION = {
7938
7969
  * @returns the file name (no directory).
7939
7970
  */
7940
7971
  function workspaceImageName(attachmentId, mediaType) {
7941
- return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
7972
+ return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION$1[mediaType]}`;
7942
7973
  }
7943
7974
  /**
7944
7975
  * Resolve the configured image folder inside the session workspace. The
@@ -8146,19 +8177,20 @@ async function deleteImageFromWorkspace(filePath, allowedWorkspaceRoots) {
8146
8177
  const MAX_BODY_BYTES$2 = 65536;
8147
8178
  /** Serve one verified durable image reference to a same-origin browser request. */
8148
8179
  async function serveImage(req, res, deps) {
8149
- if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
8150
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
8180
+ if (req.method === "GET") return serveCachedImage(req, res, deps);
8181
+ if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
8182
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
8151
8183
  const origin = req.headers.origin;
8152
8184
  const host = req.headers.host;
8153
- if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$5(res, 403, "origin-rejected");
8185
+ if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$10(res, 403, "origin-rejected");
8154
8186
  let body;
8155
8187
  try {
8156
- body = JSON.parse(await readBody$4(req));
8188
+ body = JSON.parse(await readBody$7(req));
8157
8189
  } catch {
8158
- return jsonError$5(res, 400, "invalid-request");
8190
+ return jsonError$10(res, 400, "invalid-request");
8159
8191
  }
8160
8192
  const attachment = attachmentFromRequest(body);
8161
- if (attachment === void 0) return jsonError$5(res, 400, "invalid-attachment");
8193
+ if (attachment === void 0) return jsonError$10(res, 400, "invalid-attachment");
8162
8194
  try {
8163
8195
  const stored = await deps.readImage(attachment);
8164
8196
  res.writeHead(200, {
@@ -8169,10 +8201,44 @@ async function serveImage(req, res, deps) {
8169
8201
  });
8170
8202
  res.end(stored.data);
8171
8203
  } catch {
8172
- jsonError$5(res, 404, "image-unavailable");
8204
+ jsonError$10(res, 404, "image-unavailable");
8173
8205
  }
8174
8206
  }
8175
- function sameOrigin$4(req) {
8207
+ /**
8208
+ * Cacheable GET form: attachments are content-addressed, so the response can be
8209
+ * marked immutable and revalidated with a stable ETag. Preview, copy and
8210
+ * download then hit the browser cache instead of re-reading the host.
8211
+ */
8212
+ async function serveCachedImage(req, res, deps) {
8213
+ if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
8214
+ const raw = new URL(req.url ?? "/", "http://x").searchParams.get("ref");
8215
+ const attachment = raw === null ? void 0 : decodeImageRef(raw, parseImageAttachmentRef);
8216
+ if (attachment === void 0) return jsonError$10(res, 400, "invalid-attachment");
8217
+ const etag = "\"" + String(attachment.attachmentId) + "\"";
8218
+ const cacheHeaders = {
8219
+ "cache-control": "private, max-age=31536000, immutable",
8220
+ etag
8221
+ };
8222
+ const ifNoneMatch = req.headers["if-none-match"];
8223
+ if (typeof ifNoneMatch === "string" && ifNoneMatch.split(",").some((value) => value.trim() === etag)) {
8224
+ res.writeHead(304, cacheHeaders);
8225
+ res.end();
8226
+ return;
8227
+ }
8228
+ try {
8229
+ const stored = await deps.readImage(attachment);
8230
+ res.writeHead(200, {
8231
+ "content-type": stored.ref.mediaType,
8232
+ "content-length": String(stored.data.byteLength),
8233
+ "x-content-type-options": "nosniff",
8234
+ ...cacheHeaders
8235
+ });
8236
+ res.end(stored.data);
8237
+ } catch {
8238
+ jsonError$10(res, 404, "image-unavailable");
8239
+ }
8240
+ }
8241
+ function sameOrigin$8(req) {
8176
8242
  const origin = req.headers.origin;
8177
8243
  const host = req.headers.host;
8178
8244
  if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
@@ -8180,14 +8246,14 @@ function sameOrigin$4(req) {
8180
8246
  }
8181
8247
  /** Safely delete one or more generated image files from the workspace disk. */
8182
8248
  async function serveDelete(req, res, deps) {
8183
- if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
8184
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
8185
- if (!sameOrigin$4(req)) return jsonError$5(res, 403, "origin-rejected");
8249
+ if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
8250
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
8251
+ if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
8186
8252
  let body;
8187
8253
  try {
8188
- body = JSON.parse(await readBody$4(req));
8254
+ body = JSON.parse(await readBody$7(req));
8189
8255
  } catch {
8190
- return jsonError$5(res, 400, "invalid-request");
8256
+ return jsonError$10(res, 400, "invalid-request");
8191
8257
  }
8192
8258
  const rawPaths = typeof body === "object" && body !== null && "paths" in body && Array.isArray(body.paths) ? body.paths : [];
8193
8259
  const deletedFiles = [];
@@ -8217,13 +8283,13 @@ async function serveDelete(req, res, deps) {
8217
8283
  }
8218
8284
  /** Serve on-demand image persistence into the workspace. */
8219
8285
  async function serveSaveWorkspace(req, res, deps) {
8220
- if (!sameOrigin$4(req)) return jsonError$5(res, 403, "origin-rejected");
8221
- if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
8222
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
8286
+ if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
8287
+ if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
8288
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
8223
8289
  try {
8224
- const raw = JSON.parse(await readBody$4(req));
8290
+ const raw = JSON.parse(await readBody$7(req));
8225
8291
  const attachment = parseImageAttachmentRef(raw.attachment);
8226
- if (!attachment) return jsonError$5(res, 400, "invalid-attachment");
8292
+ if (!attachment) return jsonError$10(res, 400, "invalid-attachment");
8227
8293
  if (!deps.isSaveEnabled()) {
8228
8294
  res.writeHead(200, {
8229
8295
  "content-type": "application/json",
@@ -8241,10 +8307,10 @@ async function serveSaveWorkspace(req, res, deps) {
8241
8307
  try {
8242
8308
  targetRoot = await assertWorkspaceAllowed(rawTarget, allowedRoots);
8243
8309
  } catch {
8244
- return jsonError$5(res, 403, "workspace-root-not-allowed");
8310
+ return jsonError$10(res, 403, "workspace-root-not-allowed");
8245
8311
  }
8246
8312
  const stored = await deps.readImage(attachment);
8247
- if (!stored || !stored.data) return jsonError$5(res, 404, "image-not-found");
8313
+ if (!stored || !stored.data) return jsonError$10(res, 404, "image-not-found");
8248
8314
  const savedTo = await deps.saveToWorkspace({
8249
8315
  workspaceRoot: targetRoot,
8250
8316
  attachmentId: String(attachment.attachmentId),
@@ -8276,119 +8342,1807 @@ function imageAttachmentFromMeta(meta) {
8276
8342
  if (value?.kind !== "dsh-image-gen") return void 0;
8277
8343
  return parseImageAttachmentRef(value.attachment);
8278
8344
  }
8279
- function attachmentFromRequest(value) {
8280
- return parseImageAttachmentRef(record$4(value)?.attachment);
8345
+ function attachmentFromRequest(value) {
8346
+ return parseImageAttachmentRef(record$4(value)?.attachment);
8347
+ }
8348
+ function record$4(value) {
8349
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
8350
+ }
8351
+ async function readBody$7(req) {
8352
+ const chunks = [];
8353
+ let bytes = 0;
8354
+ for await (const chunk of req) {
8355
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8356
+ bytes += buffer.byteLength;
8357
+ if (bytes > MAX_BODY_BYTES$2) throw new Error("request too large");
8358
+ chunks.push(buffer);
8359
+ }
8360
+ return Buffer.concat(chunks).toString("utf8");
8361
+ }
8362
+ function jsonError$10(res, status, code) {
8363
+ res.writeHead(status, {
8364
+ "content-type": "application/json",
8365
+ "cache-control": "no-store"
8366
+ });
8367
+ res.end(JSON.stringify({ error: code }));
8368
+ }
8369
+ //#endregion
8370
+ //#region lib/types/multipart.js
8371
+ var MultipartError = class extends Error {
8372
+ status;
8373
+ code;
8374
+ constructor(status, code) {
8375
+ super(code);
8376
+ this.status = status;
8377
+ this.code = code;
8378
+ this.name = "MultipartError";
8379
+ }
8380
+ };
8381
+ const CRLF = Buffer.from("\r\n");
8382
+ const DASH = Buffer.from("--");
8383
+ const HEADER_LIMIT = 16384;
8384
+ /**
8385
+ * Parse a multipart body incrementally. Only one part is buffered at a time,
8386
+ * so an 8 x 40 MiB upload never holds the whole request in memory.
8387
+ */
8388
+ async function readMultipart(req, boundary, options) {
8389
+ if (boundary.length === 0 || boundary.length > 200) throw new MultipartError(400, "invalid-boundary");
8390
+ const first = Buffer.from("--" + boundary);
8391
+ const delimiter = Buffer.from("\r\n--" + boundary);
8392
+ let buffer = Buffer.alloc(0);
8393
+ let state = "preamble";
8394
+ let part;
8395
+ const append = (chunk) => {
8396
+ if (part === void 0 || chunk.length === 0) return;
8397
+ part.bytes += chunk.length;
8398
+ if (part.tooLarge) return;
8399
+ if (part.bytes > options.maxPartBytes) {
8400
+ part.tooLarge = true;
8401
+ part.chunks = [];
8402
+ return;
8403
+ }
8404
+ part.chunks.push(Buffer.from(chunk));
8405
+ };
8406
+ const finishPart = async () => {
8407
+ if (part === void 0) return;
8408
+ const completed = part;
8409
+ part = void 0;
8410
+ if (completed.name !== "images") return;
8411
+ await options.onPart({
8412
+ name: completed.name,
8413
+ ...completed.filename === void 0 ? {} : { filename: completed.filename },
8414
+ ...completed.mediaType === void 0 ? {} : { mediaType: completed.mediaType },
8415
+ data: completed.tooLarge ? Buffer.alloc(0) : Buffer.concat(completed.chunks, completed.bytes),
8416
+ tooLarge: completed.tooLarge,
8417
+ bytes: completed.bytes
8418
+ });
8419
+ };
8420
+ const consume = async () => {
8421
+ while (true) {
8422
+ if (state === "done") {
8423
+ buffer = Buffer.alloc(0);
8424
+ return;
8425
+ }
8426
+ if (state === "preamble") {
8427
+ const index = buffer.indexOf(first);
8428
+ if (index === -1) {
8429
+ if (buffer.length > first.length + 4) buffer = buffer.subarray(buffer.length - (first.length + 4));
8430
+ return;
8431
+ }
8432
+ if (buffer.length < index + first.length + 2) return;
8433
+ const suffix = buffer.subarray(index + first.length, index + first.length + 2);
8434
+ if (suffix.equals(DASH)) {
8435
+ state = "done";
8436
+ buffer = Buffer.alloc(0);
8437
+ return;
8438
+ }
8439
+ if (!suffix.equals(CRLF)) throw new MultipartError(400, "invalid-multipart");
8440
+ buffer = buffer.subarray(index + first.length + 2);
8441
+ state = "headers";
8442
+ continue;
8443
+ }
8444
+ if (state === "headers") {
8445
+ const end = buffer.indexOf("\r\n\r\n");
8446
+ if (end === -1) {
8447
+ if (buffer.length > HEADER_LIMIT) throw new MultipartError(400, "invalid-headers");
8448
+ return;
8449
+ }
8450
+ const headerBuffer = buffer.subarray(0, end);
8451
+ buffer = buffer.subarray(end + 4);
8452
+ const headers = parsePartHeaders(headerBuffer);
8453
+ if (headers.name.length === 0) throw new MultipartError(400, "invalid-headers");
8454
+ part = {
8455
+ name: headers.name,
8456
+ ...headers.filename === void 0 ? {} : { filename: headers.filename },
8457
+ ...headers.mediaType === void 0 ? {} : { mediaType: headers.mediaType },
8458
+ chunks: [],
8459
+ bytes: 0,
8460
+ tooLarge: false
8461
+ };
8462
+ state = "body";
8463
+ continue;
8464
+ }
8465
+ if (state === "body") {
8466
+ const found = findDelimiter(buffer, delimiter);
8467
+ if (found === -1) {
8468
+ const keep = delimiter.length + 2;
8469
+ if (buffer.length > keep) {
8470
+ append(buffer.subarray(0, buffer.length - keep));
8471
+ buffer = buffer.subarray(buffer.length - keep);
8472
+ }
8473
+ return;
8474
+ }
8475
+ append(buffer.subarray(0, found));
8476
+ buffer = buffer.subarray(found + delimiter.length);
8477
+ const suffix = buffer.subarray(0, 2);
8478
+ buffer = buffer.subarray(2);
8479
+ await finishPart();
8480
+ if (suffix.equals(DASH)) {
8481
+ state = "done";
8482
+ buffer = Buffer.alloc(0);
8483
+ return;
8484
+ }
8485
+ if (!suffix.equals(CRLF)) throw new MultipartError(400, "invalid-multipart");
8486
+ state = "headers";
8487
+ continue;
8488
+ }
8489
+ }
8490
+ };
8491
+ for await (const chunk of req) {
8492
+ if (state === "done") continue;
8493
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8494
+ buffer = buffer.length === 0 ? incoming : Buffer.concat([buffer, incoming]);
8495
+ await consume();
8496
+ }
8497
+ await consume();
8498
+ if (state !== "done") throw new MultipartError(400, "truncated-multipart");
8499
+ }
8500
+ /** Locate a delimiter whose following two bytes are CRLF or a closing dash. */
8501
+ function findDelimiter(buffer, delimiter) {
8502
+ let from = 0;
8503
+ while (true) {
8504
+ const index = buffer.indexOf(delimiter, from);
8505
+ if (index === -1) return -1;
8506
+ if (buffer.length < index + delimiter.length + 2) return -1;
8507
+ const suffix = buffer.subarray(index + delimiter.length, index + delimiter.length + 2);
8508
+ if (suffix.equals(CRLF) || suffix.equals(DASH)) return index;
8509
+ from = index + 1;
8510
+ }
8511
+ }
8512
+ function parsePartHeaders(raw) {
8513
+ let name = "";
8514
+ let filename;
8515
+ let mediaType;
8516
+ for (const line of raw.toString("utf8").split("\r\n")) {
8517
+ const colon = line.indexOf(":");
8518
+ if (colon === -1) continue;
8519
+ const key = line.slice(0, colon).trim().toLowerCase();
8520
+ const value = line.slice(colon + 1).trim();
8521
+ if (key === "content-disposition") {
8522
+ name = dispositionParam(value, "name") ?? "";
8523
+ filename = dispositionParam(value, "filename");
8524
+ } else if (key === "content-type") mediaType = value.split(";")[0]?.trim() || void 0;
8525
+ }
8526
+ return {
8527
+ name,
8528
+ ...filename === void 0 ? {} : { filename },
8529
+ ...mediaType === void 0 ? {} : { mediaType }
8530
+ };
8531
+ }
8532
+ function dispositionParam(value, key) {
8533
+ const params = [];
8534
+ let start = 0;
8535
+ let quoted = false;
8536
+ let escaped = false;
8537
+ for (let index = 0; index < value.length; index += 1) {
8538
+ const char = value[index];
8539
+ if (escaped) {
8540
+ escaped = false;
8541
+ continue;
8542
+ }
8543
+ if (quoted && char === "\\") {
8544
+ escaped = true;
8545
+ continue;
8546
+ }
8547
+ if (char === "\"") {
8548
+ quoted = !quoted;
8549
+ continue;
8550
+ }
8551
+ if (char === ";" && !quoted) {
8552
+ params.push(value.slice(start, index));
8553
+ start = index + 1;
8554
+ }
8555
+ }
8556
+ params.push(value.slice(start));
8557
+ for (const param of params) {
8558
+ const equals = param.indexOf("=");
8559
+ if (equals <= 0 || param.slice(0, equals).trim().toLowerCase() !== key.toLowerCase()) continue;
8560
+ const raw = param.slice(equals + 1).trim();
8561
+ if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) return raw.slice(1, -1).replace(/\\(.)/g, "$1");
8562
+ return raw;
8563
+ }
8564
+ }
8565
+ //#endregion
8566
+ //#region lib/types/import-route.js
8567
+ const MAX_IMAGES_PER_REQUEST = 8;
8568
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
8569
+ const BASE64_LOOKUP = (() => {
8570
+ const table = /* @__PURE__ */ new Uint8Array(128);
8571
+ for (let index = 0; index < 64; index++) table[BASE64_ALPHABET.charCodeAt(index)] = 1;
8572
+ return table;
8573
+ })();
8574
+ /**
8575
+ * Validate base64 in linear time. A backtracking regexp looks harmless but V8
8576
+ * throws RangeError ("Maximum call stack size exceeded") once the input passes
8577
+ * a few megabytes, which turned every large import into a bare HTTP 400.
8578
+ */
8579
+ function isValidBase64(value) {
8580
+ if (value.length % 4 !== 0) return false;
8581
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
8582
+ const end = value.length - padding;
8583
+ for (let index = 0; index < end; index++) {
8584
+ const code = value.charCodeAt(index);
8585
+ if (code > 127 || BASE64_LOOKUP[code] !== 1) return false;
8586
+ }
8587
+ return true;
8588
+ }
8589
+ /** base64 inflates bytes by 4/3; allow one full batch plus JSON overhead. */
8590
+ function bodyLimit(maxImageBytes) {
8591
+ return MAX_IMAGES_PER_REQUEST * Math.ceil(maxImageBytes * 1.4) + 4096;
8592
+ }
8593
+ async function serveImport(req, res, deps) {
8594
+ if (req.method !== "POST") return jsonError$9(res, 405, "method-not-allowed");
8595
+ const origin = req.headers.origin;
8596
+ const host = req.headers.host;
8597
+ if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$9(res, 403, "origin-rejected");
8598
+ const contentType = req.headers["content-type"] ?? "";
8599
+ const boundary = multipartBoundary(contentType);
8600
+ if (boundary !== void 0) return serveMultipartImport(req, res, deps, boundary);
8601
+ if (!contentType.toLowerCase().startsWith("application/json")) return jsonError$9(res, 415, "json-required");
8602
+ let body;
8603
+ try {
8604
+ body = JSON.parse(await readBody$6(req, bodyLimit(deps.maxImageBytes)));
8605
+ } catch {
8606
+ return jsonError$9(res, 400, "invalid-request");
8607
+ }
8608
+ const images = typeof body === "object" && body !== null ? body.images : void 0;
8609
+ if (!Array.isArray(images) || images.length === 0 || images.length > MAX_IMAGES_PER_REQUEST) return jsonError$9(res, 400, "invalid-image-count");
8610
+ const saved = [];
8611
+ const failures = [];
8612
+ for (const [index, item] of images.entries()) try {
8613
+ const record = typeof item === "object" && item !== null ? item : void 0;
8614
+ if (record === void 0 || typeof record.data !== "string" || typeof record.mediaType !== "string") {
8615
+ failures.push({
8616
+ index,
8617
+ error: "invalid-item"
8618
+ });
8619
+ continue;
8620
+ }
8621
+ if (!deps.mediaTypes.includes(record.mediaType)) {
8622
+ failures.push({
8623
+ index,
8624
+ error: `unsupported-media-type: ${record.mediaType}`
8625
+ });
8626
+ continue;
8627
+ }
8628
+ if (!isValidBase64(record.data)) {
8629
+ failures.push({
8630
+ index,
8631
+ error: "invalid-base64"
8632
+ });
8633
+ continue;
8634
+ }
8635
+ const bytes = Buffer.from(record.data, "base64");
8636
+ if (bytes.byteLength === 0 || bytes.byteLength > deps.maxImageBytes) {
8637
+ failures.push({
8638
+ index,
8639
+ error: `size-out-of-range (max ${String(deps.maxImageBytes)} bytes)`
8640
+ });
8641
+ continue;
8642
+ }
8643
+ try {
8644
+ const persisted = await persistImport(deps, bytes, record.mediaType, record.name);
8645
+ saved.push(persisted);
8646
+ try {
8647
+ deps.warmThumbnail?.(persisted.attachment);
8648
+ } catch {}
8649
+ } catch {
8650
+ failures.push({
8651
+ index,
8652
+ error: "save-failed"
8653
+ });
8654
+ }
8655
+ } catch {
8656
+ failures.push({
8657
+ index,
8658
+ error: "internal-error"
8659
+ });
8660
+ }
8661
+ res.writeHead(200, {
8662
+ "content-type": "application/json",
8663
+ "cache-control": "no-store"
8664
+ });
8665
+ res.end(JSON.stringify({
8666
+ images: saved,
8667
+ failures
8668
+ }));
8669
+ }
8670
+ async function serveMultipartImport(req, res, deps, boundary) {
8671
+ const saved = [];
8672
+ const failures = [];
8673
+ let tempDir;
8674
+ try {
8675
+ tempDir = await mkdtemp(join(tmpdir(), "dsh-image-import-"));
8676
+ const spooled = [];
8677
+ let index = 0;
8678
+ await readMultipart(req, boundary, {
8679
+ maxPartBytes: deps.maxImageBytes,
8680
+ onPart: async (part) => {
8681
+ const current = index++;
8682
+ if (current >= MAX_IMAGES_PER_REQUEST) {
8683
+ failures.push({
8684
+ index: current,
8685
+ error: "invalid-image-count"
8686
+ });
8687
+ return;
8688
+ }
8689
+ if (part.tooLarge || part.bytes === 0 || part.bytes > deps.maxImageBytes) {
8690
+ failures.push({
8691
+ index: current,
8692
+ error: "size-out-of-range (max " + String(deps.maxImageBytes) + " bytes)"
8693
+ });
8694
+ return;
8695
+ }
8696
+ const declared = part.mediaType !== void 0 && deps.mediaTypes.includes(part.mediaType) ? part.mediaType : mediaTypeFromFilename(part.filename);
8697
+ if (declared === void 0 || !deps.mediaTypes.includes(declared)) {
8698
+ failures.push({
8699
+ index: current,
8700
+ error: "unsupported-media-type: " + (declared ?? "unknown")
8701
+ });
8702
+ return;
8703
+ }
8704
+ const path = join(tempDir, String(current) + ".part");
8705
+ await writeFile(path, part.data);
8706
+ spooled.push({
8707
+ index: current,
8708
+ path,
8709
+ mediaType: declared,
8710
+ ...part.filename === void 0 ? {} : { filename: part.filename },
8711
+ bytes: part.bytes
8712
+ });
8713
+ }
8714
+ });
8715
+ for (const part of spooled) try {
8716
+ const persisted = await persistImport(deps, await readFile(part.path), part.mediaType, part.filename);
8717
+ saved.push(persisted);
8718
+ try {
8719
+ deps.warmThumbnail?.(persisted.attachment);
8720
+ } catch {}
8721
+ } catch {
8722
+ failures.push({
8723
+ index: part.index,
8724
+ error: "save-failed"
8725
+ });
8726
+ }
8727
+ } catch (err) {
8728
+ if (err instanceof MultipartError) return jsonError$9(res, err.status, err.code);
8729
+ return jsonError$9(res, 400, "invalid-request");
8730
+ } finally {
8731
+ if (tempDir !== void 0) await rm(tempDir, {
8732
+ recursive: true,
8733
+ force: true
8734
+ }).catch(() => void 0);
8735
+ }
8736
+ if (saved.length === 0 && failures.length === 0) return jsonError$9(res, 400, "invalid-image-count");
8737
+ failures.sort((a, b) => a.index - b.index);
8738
+ res.writeHead(200, {
8739
+ "content-type": "application/json",
8740
+ "cache-control": "no-store"
8741
+ });
8742
+ res.end(JSON.stringify({
8743
+ images: saved,
8744
+ failures
8745
+ }));
8746
+ }
8747
+ /** Save one image and, when the attachment layer normalized it, keep the raw copy. */
8748
+ async function persistImport(deps, bytes, mediaType, name) {
8749
+ const attachment = await deps.saveImage({
8750
+ data: bytes,
8751
+ mediaType,
8752
+ ...typeof name === "string" && name.length > 0 ? { name } : {}
8753
+ });
8754
+ let original;
8755
+ if (deps.saveOriginal !== void 0 && (attachment.mediaType !== mediaType || attachment.bytes !== bytes.byteLength)) original = await deps.saveOriginal({
8756
+ data: bytes,
8757
+ mediaType
8758
+ });
8759
+ return {
8760
+ attachment,
8761
+ ...original === void 0 ? {} : { original }
8762
+ };
8763
+ }
8764
+ function multipartBoundary(contentType) {
8765
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) return void 0;
8766
+ const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
8767
+ const value = (match?.[1] ?? match?.[2] ?? "").trim();
8768
+ return value.length > 0 ? value : void 0;
8769
+ }
8770
+ const FILENAME_MEDIA_TYPES = {
8771
+ png: "image/png",
8772
+ jpg: "image/jpeg",
8773
+ jpeg: "image/jpeg",
8774
+ webp: "image/webp",
8775
+ gif: "image/gif"
8776
+ };
8777
+ function mediaTypeFromFilename(name) {
8778
+ if (name === void 0) return void 0;
8779
+ const dot = name.lastIndexOf(".");
8780
+ if (dot === -1) return void 0;
8781
+ return FILENAME_MEDIA_TYPES[name.slice(dot + 1).toLowerCase()];
8782
+ }
8783
+ async function readBody$6(req, maxBytes) {
8784
+ const chunks = [];
8785
+ let bytes = 0;
8786
+ for await (const chunk of req) {
8787
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8788
+ bytes += buffer.byteLength;
8789
+ if (bytes > maxBytes) throw new Error("request-too-large");
8790
+ chunks.push(buffer);
8791
+ }
8792
+ return Buffer.concat(chunks).toString("utf8");
8793
+ }
8794
+ function jsonError$9(res, status, code) {
8795
+ res.writeHead(status, {
8796
+ "content-type": "application/json",
8797
+ "cache-control": "no-store"
8798
+ });
8799
+ res.end(JSON.stringify({ error: code }));
8800
+ }
8801
+ //#endregion
8802
+ //#region lib/types/zip-stream.js
8803
+ const LOCAL_HEADER_BYTES = 30;
8804
+ const CENTRAL_HEADER_BYTES = 46;
8805
+ const END_OF_CENTRAL_BYTES = 22;
8806
+ const ZIP64_END_BYTES = 56;
8807
+ const ZIP64_LOCATOR_BYTES = 20;
8808
+ const ZIP64_EXTRA_ID = 1;
8809
+ const ZIP64_VERSION = 45;
8810
+ const ZIP32_VERSION = 20;
8811
+ const UINT32_MAX = 4294967295;
8812
+ const UINT16_MAX = 65535;
8813
+ const MAX_ENTRIES = 4294967294;
8814
+ /** Raised when the selection cannot be represented even with ZIP64 records. */
8815
+ var ZipTooLargeError = class extends Error {
8816
+ constructor(message) {
8817
+ super(message);
8818
+ this.name = "ZipTooLargeError";
8819
+ }
8820
+ };
8821
+ /**
8822
+ * Write a ZIP archive straight to an HTTP response.
8823
+ *
8824
+ * Images are already compressed, so entries are stored (method 0) and the only
8825
+ * CPU work is the CRC — `zlib.crc32` runs in native code. Sizes are known before
8826
+ * each header is written, so no data descriptor is needed; entries that cross
8827
+ * the ZIP32 limits get ZIP64 extra fields, and the archive ends with a ZIP64
8828
+ * end-of-central-directory record when any entry needs one.
8829
+ */
8830
+ var ZipStreamWriter = class {
8831
+ res;
8832
+ options;
8833
+ central = [];
8834
+ names = /* @__PURE__ */ new Set();
8835
+ offset = 0;
8836
+ entries = 0;
8837
+ closed = false;
8838
+ zip64Archive = false;
8839
+ constructor(res, options = {}) {
8840
+ this.res = res;
8841
+ this.options = options;
8842
+ }
8843
+ get entryCount() {
8844
+ return this.entries;
8845
+ }
8846
+ get bytesWritten() {
8847
+ return this.offset;
8848
+ }
8849
+ get isClosed() {
8850
+ return this.closed;
8851
+ }
8852
+ /** Stop writing as soon as the browser closes the download. */
8853
+ markClientClosed() {
8854
+ this.closed = true;
8855
+ }
8856
+ /** Append one file entry. */
8857
+ async add(name, data) {
8858
+ if (this.entries >= MAX_ENTRIES) throw new ZipTooLargeError("ZIP export exceeds the entry limit");
8859
+ const safeName = this.uniqueName(name);
8860
+ const nameBytes = Buffer.from(safeName, "utf8");
8861
+ const size = data.byteLength;
8862
+ const checksum = crc32(data);
8863
+ const { time, date } = dosDateTime();
8864
+ const headerOffset = this.offset;
8865
+ const zip64 = this.options.zip64 ?? "auto";
8866
+ const size64 = zip64 === "always" || size >= UINT32_MAX;
8867
+ const offset64 = zip64 === "always" || headerOffset >= UINT32_MAX;
8868
+ if (size64 || offset64) this.zip64Archive = true;
8869
+ const localExtra = size64 ? zip64Extra([size, size]) : Buffer.alloc(0);
8870
+ const version = size64 ? ZIP64_VERSION : ZIP32_VERSION;
8871
+ const local = Buffer.alloc(LOCAL_HEADER_BYTES);
8872
+ local.writeUInt32LE(67324752, 0);
8873
+ local.writeUInt16LE(version, 4);
8874
+ local.writeUInt16LE(2048, 6);
8875
+ local.writeUInt16LE(0, 8);
8876
+ local.writeUInt16LE(time, 10);
8877
+ local.writeUInt16LE(date, 12);
8878
+ local.writeUInt32LE(checksum, 14);
8879
+ local.writeUInt32LE(size64 ? UINT32_MAX : size, 18);
8880
+ local.writeUInt32LE(size64 ? UINT32_MAX : size, 22);
8881
+ local.writeUInt16LE(nameBytes.length, 26);
8882
+ local.writeUInt16LE(localExtra.length, 28);
8883
+ await this.write(local);
8884
+ await this.write(nameBytes);
8885
+ if (localExtra.length > 0) await this.write(localExtra);
8886
+ await this.write(data);
8887
+ this.central.push(this.centralRecord({
8888
+ nameBytes,
8889
+ checksum,
8890
+ size,
8891
+ headerOffset,
8892
+ size64,
8893
+ offset64,
8894
+ time,
8895
+ date
8896
+ }));
8897
+ this.entries += 1;
8898
+ }
8899
+ /** Write the central directory and finish the response. */
8900
+ async finish() {
8901
+ const centralOffset = this.offset;
8902
+ let centralSize = 0;
8903
+ for (const record of this.central) centralSize += record.byteLength;
8904
+ const zip64 = this.zip64Archive || this.entries > UINT16_MAX || centralOffset >= UINT32_MAX || centralSize >= UINT32_MAX;
8905
+ if (zip64) this.zip64Archive = true;
8906
+ for (const record of this.central) await this.write(record);
8907
+ if (zip64) {
8908
+ const zip64Offset = this.offset;
8909
+ const end64 = Buffer.alloc(ZIP64_END_BYTES);
8910
+ end64.writeUInt32LE(101075792, 0);
8911
+ end64.writeBigUInt64LE(44n, 4);
8912
+ end64.writeUInt16LE(ZIP64_VERSION, 12);
8913
+ end64.writeUInt16LE(ZIP64_VERSION, 14);
8914
+ end64.writeUInt32LE(0, 16);
8915
+ end64.writeUInt32LE(0, 20);
8916
+ end64.writeBigUInt64LE(BigInt(this.entries), 24);
8917
+ end64.writeBigUInt64LE(BigInt(this.entries), 32);
8918
+ end64.writeBigUInt64LE(BigInt(centralSize), 40);
8919
+ end64.writeBigUInt64LE(BigInt(centralOffset), 48);
8920
+ await this.write(end64);
8921
+ const locator = Buffer.alloc(ZIP64_LOCATOR_BYTES);
8922
+ locator.writeUInt32LE(117853008, 0);
8923
+ locator.writeUInt32LE(0, 4);
8924
+ locator.writeBigUInt64LE(BigInt(zip64Offset), 8);
8925
+ locator.writeUInt32LE(1, 16);
8926
+ await this.write(locator);
8927
+ }
8928
+ const end = Buffer.alloc(END_OF_CENTRAL_BYTES);
8929
+ end.writeUInt32LE(101010256, 0);
8930
+ end.writeUInt16LE(0, 4);
8931
+ end.writeUInt16LE(0, 6);
8932
+ end.writeUInt16LE(zip64 ? UINT16_MAX : this.entries, 8);
8933
+ end.writeUInt16LE(zip64 ? UINT16_MAX : this.entries, 10);
8934
+ end.writeUInt32LE(zip64 ? UINT32_MAX : centralSize, 12);
8935
+ end.writeUInt32LE(zip64 ? UINT32_MAX : centralOffset, 16);
8936
+ end.writeUInt16LE(0, 20);
8937
+ await this.write(end);
8938
+ this.res.end();
8939
+ }
8940
+ centralRecord(entry) {
8941
+ const extraValues = [...entry.size64 ? [entry.size, entry.size] : [], ...entry.offset64 ? [entry.headerOffset] : []];
8942
+ const extra = extraValues.length > 0 ? zip64Extra(extraValues) : Buffer.alloc(0);
8943
+ const central = Buffer.alloc(CENTRAL_HEADER_BYTES);
8944
+ central.writeUInt32LE(33639248, 0);
8945
+ central.writeUInt16LE(entry.size64 || entry.offset64 ? ZIP64_VERSION : ZIP32_VERSION, 4);
8946
+ central.writeUInt16LE(entry.size64 || entry.offset64 ? ZIP64_VERSION : ZIP32_VERSION, 6);
8947
+ central.writeUInt16LE(2048, 8);
8948
+ central.writeUInt16LE(0, 10);
8949
+ central.writeUInt16LE(entry.time, 12);
8950
+ central.writeUInt16LE(entry.date, 14);
8951
+ central.writeUInt32LE(entry.checksum, 16);
8952
+ central.writeUInt32LE(entry.size64 ? UINT32_MAX : entry.size, 20);
8953
+ central.writeUInt32LE(entry.size64 ? UINT32_MAX : entry.size, 24);
8954
+ central.writeUInt16LE(entry.nameBytes.length, 28);
8955
+ central.writeUInt16LE(extra.length, 30);
8956
+ central.writeUInt16LE(0, 32);
8957
+ central.writeUInt16LE(0, 34);
8958
+ central.writeUInt16LE(0, 36);
8959
+ central.writeUInt32LE(0, 38);
8960
+ central.writeUInt32LE(entry.offset64 ? UINT32_MAX : entry.headerOffset, 42);
8961
+ return Buffer.concat([
8962
+ central,
8963
+ entry.nameBytes,
8964
+ extra
8965
+ ]);
8966
+ }
8967
+ async write(chunk) {
8968
+ if (this.closed) throw new Error("client closed the download");
8969
+ if (this.offset + chunk.byteLength > (this.options.maxBytes ?? Number.POSITIVE_INFINITY)) throw new ZipTooLargeError("ZIP export exceeds the archive byte limit");
8970
+ this.offset += chunk.byteLength;
8971
+ if (this.res.write(chunk)) return;
8972
+ await new Promise((resolve, reject) => {
8973
+ const cleanup = () => {
8974
+ this.res.off("drain", onDrain);
8975
+ this.res.off("close", onClose);
8976
+ };
8977
+ const onDrain = () => {
8978
+ cleanup();
8979
+ resolve();
8980
+ };
8981
+ const onClose = () => {
8982
+ cleanup();
8983
+ reject(/* @__PURE__ */ new Error("client closed the download"));
8984
+ };
8985
+ this.res.once("drain", onDrain);
8986
+ this.res.once("close", onClose);
8987
+ });
8988
+ }
8989
+ uniqueName(raw) {
8990
+ const cleaned = sanitizeName(raw);
8991
+ if (!this.names.has(cleaned)) {
8992
+ this.names.add(cleaned);
8993
+ return cleaned;
8994
+ }
8995
+ const dot = cleaned.lastIndexOf(".");
8996
+ const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned;
8997
+ const extension = dot > 0 ? cleaned.slice(dot) : "";
8998
+ let suffix = 1;
8999
+ let candidate = cleaned;
9000
+ while (this.names.has(candidate)) {
9001
+ candidate = stem + "-" + suffix + extension;
9002
+ suffix += 1;
9003
+ }
9004
+ this.names.add(candidate);
9005
+ return candidate;
9006
+ }
9007
+ };
9008
+ /** Encode a ZIP64 extra field: id, size, then one or more 8-byte values. */
9009
+ function zip64Extra(values) {
9010
+ const body = Buffer.alloc(values.length * 8);
9011
+ values.forEach((value, index) => body.writeBigUInt64LE(BigInt(value), index * 8));
9012
+ const extra = Buffer.alloc(4 + body.length);
9013
+ extra.writeUInt16LE(ZIP64_EXTRA_ID, 0);
9014
+ extra.writeUInt16LE(body.length, 2);
9015
+ body.copy(extra, 4);
9016
+ return extra;
9017
+ }
9018
+ /** Keep download names portable: no paths, no control characters, bounded length. */
9019
+ function sanitizeName(raw) {
9020
+ const cleaned = (raw.split(/[\\/]/).pop() ?? "image").replace(/[^A-Za-z0-9._@-]+/g, "_").replace(/^[._]+/, "").slice(0, 120);
9021
+ return cleaned.length > 0 ? cleaned : "image";
9022
+ }
9023
+ function dosDateTime(when = /* @__PURE__ */ new Date()) {
9024
+ return {
9025
+ time: when.getHours() << 11 | when.getMinutes() << 5 | Math.floor(when.getSeconds() / 2),
9026
+ date: (Math.max(when.getFullYear() - 1980, 0) << 9 | when.getMonth() + 1 << 5 | when.getDate()) & 65535
9027
+ };
9028
+ }
9029
+ //#endregion
9030
+ //#region lib/types/zip-progress.js
9031
+ const jobs = /* @__PURE__ */ new Map();
9032
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
9033
+ const JOB_TTL_MS = 6e5;
9034
+ /** Tokens are client-generated identifiers; anything else is ignored. */
9035
+ function isValidZipToken(token) {
9036
+ return TOKEN_PATTERN.test(token);
9037
+ }
9038
+ function notify(job) {
9039
+ job.updatedAt = Date.now();
9040
+ for (const listener of [...job.listeners]) listener();
9041
+ }
9042
+ function prune() {
9043
+ const cutoff = Date.now() - JOB_TTL_MS;
9044
+ for (const [token, job] of jobs) if (job.updatedAt < cutoff) jobs.delete(token);
9045
+ }
9046
+ function ensureJob(token) {
9047
+ if (!isValidZipToken(token)) return void 0;
9048
+ let job = jobs.get(token);
9049
+ if (job === void 0) {
9050
+ prune();
9051
+ job = {
9052
+ token,
9053
+ total: 0,
9054
+ done: 0,
9055
+ state: "running",
9056
+ cancelled: false,
9057
+ listeners: /* @__PURE__ */ new Set(),
9058
+ updatedAt: Date.now()
9059
+ };
9060
+ jobs.set(token, job);
9061
+ }
9062
+ return job;
9063
+ }
9064
+ /** The SSE listener may connect first; createZipJob then fills in the total. */
9065
+ function createZipJob(token, total) {
9066
+ const job = ensureJob(token);
9067
+ if (job === void 0) return;
9068
+ job.total = total;
9069
+ job.done = 0;
9070
+ job.state = "running";
9071
+ notify(job);
9072
+ }
9073
+ function updateZipJob(token, done) {
9074
+ const job = jobs.get(token);
9075
+ if (job === void 0 || job.state !== "running") return;
9076
+ job.done = Math.max(job.done, done);
9077
+ notify(job);
9078
+ }
9079
+ function finishZipJob(token, state) {
9080
+ const job = jobs.get(token);
9081
+ if (job === void 0 || job.state !== "running") return;
9082
+ job.state = state;
9083
+ if (state === "done") job.done = job.total;
9084
+ notify(job);
9085
+ for (const listener of [...job.listeners]) job.listeners.delete(listener);
9086
+ }
9087
+ function isZipJobCancelled(token) {
9088
+ return jobs.get(token)?.cancelled === true;
9089
+ }
9090
+ function cancelZipJob(token) {
9091
+ const job = jobs.get(token);
9092
+ if (job === void 0) return false;
9093
+ if (job.state !== "running") return false;
9094
+ job.cancelled = true;
9095
+ notify(job);
9096
+ return true;
9097
+ }
9098
+ /**
9099
+ * Server-sent events for one export: the client keeps the fast iframe download
9100
+ * and still sees "N/M" progress plus completion.
9101
+ */
9102
+ function serveZipProgress(req, res) {
9103
+ if (req.method !== "GET") return jsonError$8(res, 405, "method-not-allowed");
9104
+ const job = ensureJob(new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "");
9105
+ if (job === void 0) return jsonError$8(res, 404, "unknown-job");
9106
+ res.writeHead(200, {
9107
+ "content-type": "text/event-stream; charset=utf-8",
9108
+ "cache-control": "no-store",
9109
+ connection: "keep-alive",
9110
+ "x-accel-buffering": "no"
9111
+ });
9112
+ const send = () => {
9113
+ if (res.writableEnded) return;
9114
+ res.write("event: progress\ndata: " + JSON.stringify({
9115
+ done: job.done,
9116
+ total: job.total,
9117
+ state: job.state
9118
+ }) + "\n\n");
9119
+ if (job.state !== "running") {
9120
+ job.listeners.delete(send);
9121
+ res.end();
9122
+ }
9123
+ };
9124
+ send();
9125
+ if (job.state === "running") {
9126
+ job.listeners.add(send);
9127
+ const heartbeat = setInterval(() => {
9128
+ if (!res.writableEnded) res.write(": ping\n\n");
9129
+ }, 15e3);
9130
+ heartbeat.unref?.();
9131
+ res.on("close", () => {
9132
+ clearInterval(heartbeat);
9133
+ job.listeners.delete(send);
9134
+ });
9135
+ }
9136
+ }
9137
+ /** Stop an in-flight export; the ZIP response is destroyed so the download fails. */
9138
+ function serveZipCancel(req, res) {
9139
+ if (req.method !== "POST") return jsonError$8(res, 405, "method-not-allowed");
9140
+ const ok = cancelZipJob(new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "");
9141
+ res.writeHead(200, {
9142
+ "content-type": "application/json; charset=utf-8",
9143
+ "cache-control": "no-store"
9144
+ });
9145
+ res.end(JSON.stringify({ ok }));
9146
+ }
9147
+ function jsonError$8(res, status, code) {
9148
+ res.writeHead(status, {
9149
+ "content-type": "application/json; charset=utf-8",
9150
+ "cache-control": "no-store"
9151
+ });
9152
+ res.end(JSON.stringify({ error: code }));
9153
+ }
9154
+ //#endregion
9155
+ //#region lib/types/vault-store.js
9156
+ /** Content-addressed store for original image bytes the attachment layer normalized. */
9157
+ const VAULT_KEY_PATTERN = /^[a-f0-9]{64}$/;
9158
+ const EXTENSION = {
9159
+ "image/png": "png",
9160
+ "image/jpeg": "jpg",
9161
+ "image/webp": "webp",
9162
+ "image/gif": "gif"
9163
+ };
9164
+ const MEDIA_TYPE = {
9165
+ png: "image/png",
9166
+ jpg: "image/jpeg",
9167
+ webp: "image/webp",
9168
+ gif: "image/gif"
9169
+ };
9170
+ function resolveDshHome$1(home) {
9171
+ return home?.trim() || process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
9172
+ }
9173
+ function resolveVaultDir(home) {
9174
+ return join(resolveDshHome$1(home), "image-gen", "vault");
9175
+ }
9176
+ /** Resolve a configured vault path; relative values stay below the DSH home, not the process cwd. */
9177
+ function resolveVaultPath(configured, home) {
9178
+ const value = configured.trim();
9179
+ if (value.length === 0) return resolveVaultDir(home);
9180
+ return isAbsolute(value) ? value : join(resolveDshHome$1(home), value);
9181
+ }
9182
+ function isValidVaultKey(key) {
9183
+ return VAULT_KEY_PATTERN.test(key);
9184
+ }
9185
+ /** Persist one original file; the same bytes are shared through content addressing. */
9186
+ async function saveOriginal(config, input) {
9187
+ if (!config.enabled) return void 0;
9188
+ const extension = EXTENSION[input.mediaType];
9189
+ if (extension === void 0) return void 0;
9190
+ const key = createHash("sha256").update(input.data).digest("hex");
9191
+ const target = join(config.dir, key.slice(0, 2), key + "." + extension);
9192
+ try {
9193
+ const existing = await stat(target);
9194
+ if (existing.size === input.data.byteLength) return {
9195
+ key,
9196
+ bytes: existing.size,
9197
+ mediaType: input.mediaType
9198
+ };
9199
+ } catch {}
9200
+ await mkdir(dirname(target), { recursive: true });
9201
+ const temporary = target + ".tmp-" + randomUUID();
9202
+ try {
9203
+ await writeFile(temporary, input.data);
9204
+ await rename(temporary, target);
9205
+ } catch (err) {
9206
+ await unlink(temporary).catch(() => void 0);
9207
+ throw err;
9208
+ }
9209
+ return {
9210
+ key,
9211
+ bytes: input.data.byteLength,
9212
+ mediaType: input.mediaType
9213
+ };
9214
+ }
9215
+ async function readOriginal(config, key, preferredMediaType) {
9216
+ if (!config.enabled || !isValidVaultKey(key)) return void 0;
9217
+ const preferred = preferredMediaType === void 0 ? void 0 : EXTENSION[preferredMediaType];
9218
+ const all = Object.entries(MEDIA_TYPE);
9219
+ const candidates = preferred === void 0 ? all : [[preferred, preferredMediaType], ...all.filter(([extension]) => extension !== preferred)];
9220
+ for (const [extension, mediaType] of candidates) try {
9221
+ return {
9222
+ data: await readFile(join(config.dir, key.slice(0, 2), key + "." + extension)),
9223
+ mediaType
9224
+ };
9225
+ } catch {}
9226
+ }
9227
+ async function listVaultKeys(config) {
9228
+ return (await listVaultFiles(config)).map((file) => file.key);
9229
+ }
9230
+ async function deleteVaultKeys(config, keys) {
9231
+ if (!config.enabled) return {
9232
+ removed: 0,
9233
+ bytes: 0
9234
+ };
9235
+ const targets = /* @__PURE__ */ new Set();
9236
+ for (const key of keys) if (isValidVaultKey(key)) targets.add(key);
9237
+ if (targets.size === 0) return {
9238
+ removed: 0,
9239
+ bytes: 0
9240
+ };
9241
+ const files = await listVaultFiles(config);
9242
+ let removed = 0;
9243
+ let bytes = 0;
9244
+ for (const file of files) {
9245
+ if (!targets.has(file.key)) continue;
9246
+ try {
9247
+ await unlink(file.path);
9248
+ removed += 1;
9249
+ bytes += file.bytes;
9250
+ } catch {}
9251
+ }
9252
+ await removeEmptyShards(config);
9253
+ return {
9254
+ removed,
9255
+ bytes
9256
+ };
9257
+ }
9258
+ async function removeEmptyShards(config) {
9259
+ try {
9260
+ for (const shard of await readdir(config.dir, { withFileTypes: true })) {
9261
+ if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
9262
+ try {
9263
+ await rmdir(join(config.dir, shard.name));
9264
+ } catch {}
9265
+ }
9266
+ } catch {}
9267
+ }
9268
+ async function listVaultFiles(config) {
9269
+ const files = [];
9270
+ try {
9271
+ for (const shard of await readdir(config.dir, { withFileTypes: true })) {
9272
+ if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
9273
+ for (const entry of await readdir(join(config.dir, shard.name), { withFileTypes: true })) {
9274
+ if (!entry.isFile()) continue;
9275
+ const dot = entry.name.lastIndexOf(".");
9276
+ const key = dot > 0 ? entry.name.slice(0, dot) : entry.name;
9277
+ if (!isValidVaultKey(key)) continue;
9278
+ const path = join(config.dir, shard.name, entry.name);
9279
+ try {
9280
+ const info = await stat(path);
9281
+ files.push({
9282
+ path,
9283
+ key,
9284
+ bytes: info.size,
9285
+ mtimeMs: info.mtimeMs
9286
+ });
9287
+ } catch {}
9288
+ }
9289
+ }
9290
+ } catch {
9291
+ return [];
9292
+ }
9293
+ return files;
9294
+ }
9295
+ /** Delete only the explicitly requested originals; callers must prove they are unreferenced. */
9296
+ //#endregion
9297
+ //#region lib/types/zip-route.js
9298
+ const DEFAULT_MAX_ENTRIES = 2e3;
9299
+ const DEFAULT_MAX_BODY_BYTES = 4194304;
9300
+ /** Sanity ceiling: ZIP64 removed the 4 GiB cap, not an accidental 2000 × 40 GiB. */
9301
+ const MAX_ARCHIVE_BYTES = 68719476736;
9302
+ const IMAGE_EXTENSIONS = {
9303
+ "image/png": "png",
9304
+ "image/jpeg": "jpg",
9305
+ "image/webp": "webp",
9306
+ "image/gif": "gif"
9307
+ };
9308
+ /**
9309
+ * Package selected gallery images into one ZIP and stream it to the browser.
9310
+ *
9311
+ * The request is a plain form post (`items` = JSON array of {name, attachment}),
9312
+ * so the renderer never builds the archive: the browser download manager owns
9313
+ * the transfer while this route reads attachments one at a time.
9314
+ */
9315
+ async function serveZip(req, res, deps) {
9316
+ if (req.method !== "POST") return jsonError$7(res, 405, "method-not-allowed");
9317
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/x-www-form-urlencoded")) return jsonError$7(res, 415, "form-required");
9318
+ if (!sameOrigin$7(req)) return jsonError$7(res, 403, "origin-rejected");
9319
+ const queryToken = new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "";
9320
+ let jobToken = isValidZipToken(queryToken) ? queryToken : "";
9321
+ const fail = (status, code) => {
9322
+ if (jobToken !== "") {
9323
+ createZipJob(jobToken, 0);
9324
+ finishZipJob(jobToken, "error");
9325
+ }
9326
+ jsonError$7(res, status, code);
9327
+ };
9328
+ let body;
9329
+ try {
9330
+ body = await readBody$5(req, deps.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
9331
+ } catch {
9332
+ return fail(413, "request-too-large");
9333
+ }
9334
+ const params = new URLSearchParams(body);
9335
+ const tokenParam = params.get("token") ?? "";
9336
+ if (jobToken === "" && isValidZipToken(tokenParam)) jobToken = tokenParam;
9337
+ const rawItems = params.get("items");
9338
+ if (rawItems === null) return fail(400, "invalid-request");
9339
+ let parsed;
9340
+ try {
9341
+ parsed = JSON.parse(rawItems);
9342
+ } catch {
9343
+ return fail(400, "invalid-request");
9344
+ }
9345
+ if (!Array.isArray(parsed)) return fail(400, "invalid-request");
9346
+ const items = [];
9347
+ for (const entry of parsed) {
9348
+ const record = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : void 0;
9349
+ const attachment = parseImageAttachmentRef(record?.attachment);
9350
+ if (attachment === void 0) continue;
9351
+ items.push({
9352
+ attachment,
9353
+ ...typeof record?.name === "string" ? { name: record.name } : {},
9354
+ ...typeof record?.originalKey === "string" && isValidVaultKey(record.originalKey) ? { originalKey: record.originalKey } : {},
9355
+ ...typeof record?.originalMediaType === "string" ? { originalMediaType: record.originalMediaType } : {}
9356
+ });
9357
+ }
9358
+ if (items.length === 0) return fail(400, "no-attachments");
9359
+ const maxEntries = deps.maxEntries ?? DEFAULT_MAX_ENTRIES;
9360
+ if (items.length > maxEntries) return fail(413, "too-many-attachments");
9361
+ let declaredBytes = 0;
9362
+ for (const item of items) declaredBytes += item.attachment.bytes;
9363
+ const maxArchiveBytes = deps.maxArchiveBytes ?? MAX_ARCHIVE_BYTES;
9364
+ if (declaredBytes > maxArchiveBytes) return fail(413, "archive-too-large");
9365
+ if (jobToken !== "") createZipJob(jobToken, items.length);
9366
+ const filename = "dsh-gallery-" + (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "") + ".zip";
9367
+ res.writeHead(200, {
9368
+ "content-type": "application/zip",
9369
+ "content-disposition": "attachment; filename=\"" + filename + "\"",
9370
+ "cache-control": "no-store",
9371
+ "x-content-type-options": "nosniff"
9372
+ });
9373
+ const zip = new ZipStreamWriter(res, { maxBytes: maxArchiveBytes });
9374
+ const skipped = [];
9375
+ const onClose = () => {
9376
+ if (res.writableFinished) return;
9377
+ zip.markClientClosed();
9378
+ if (jobToken !== "") finishZipJob(jobToken, "cancelled");
9379
+ };
9380
+ res.on("close", onClose);
9381
+ const stopped = () => {
9382
+ if (zip.isClosed) return true;
9383
+ if (jobToken !== "" && isZipJobCancelled(jobToken)) {
9384
+ finishZipJob(jobToken, "cancelled");
9385
+ res.destroy();
9386
+ return true;
9387
+ }
9388
+ return false;
9389
+ };
9390
+ try {
9391
+ for (let index = 0; index < items.length; index += 1) {
9392
+ if (stopped()) return;
9393
+ const item = items[index];
9394
+ let original;
9395
+ if (item.originalKey !== void 0 && deps.readVault !== void 0) {
9396
+ try {
9397
+ original = await deps.readVault(item.originalKey, item.originalMediaType);
9398
+ } catch {
9399
+ original = void 0;
9400
+ }
9401
+ if (stopped()) return;
9402
+ }
9403
+ const mediaType = original?.mediaType ?? item.attachment.mediaType;
9404
+ const extension = IMAGE_EXTENSIONS[mediaType] ?? "png";
9405
+ const fallback = "dsh-" + String(item.attachment.attachmentId).replace(/^sha256:/, "").slice(0, 8) + "." + extension;
9406
+ const entryName = item.name === void 0 ? fallback : original === void 0 ? replaceExtension(item.name, extension) : item.name;
9407
+ try {
9408
+ if (original !== void 0) await zip.add(entryName, Buffer.from(original.data));
9409
+ else {
9410
+ const stored = await deps.readImage(item.attachment);
9411
+ if (stopped()) return;
9412
+ await zip.add(entryName, Buffer.from(stored.data));
9413
+ }
9414
+ } catch (err) {
9415
+ if (isClosed(zip, err)) return;
9416
+ if (err instanceof ZipTooLargeError) throw err;
9417
+ skipped.push(String(item.attachment.attachmentId) + " " + (err instanceof Error ? err.message : "unavailable"));
9418
+ }
9419
+ if (jobToken !== "") updateZipJob(jobToken, index + 1);
9420
+ }
9421
+ if (skipped.length > 0) {
9422
+ if (stopped()) return;
9423
+ await zip.add("skipped.txt", Buffer.from(skipped.join("\n") + "\n", "utf8"));
9424
+ }
9425
+ if (stopped()) return;
9426
+ await zip.finish();
9427
+ await waitForResponseFinish(res);
9428
+ if (jobToken !== "") finishZipJob(jobToken, "done");
9429
+ } catch (err) {
9430
+ if (isClosed(zip, err)) return;
9431
+ if (jobToken !== "") finishZipJob(jobToken, "error");
9432
+ if (!res.headersSent) {
9433
+ jsonError$7(res, err instanceof ZipTooLargeError ? 413 : 500, err instanceof ZipTooLargeError ? "archive-too-large" : "zip-failed");
9434
+ return;
9435
+ }
9436
+ res.destroy(err instanceof Error ? err : void 0);
9437
+ } finally {
9438
+ res.off("close", onClose);
9439
+ }
9440
+ }
9441
+ function waitForResponseFinish(res) {
9442
+ if (res.writableFinished) return Promise.resolve();
9443
+ if (res.destroyed) return Promise.reject(/* @__PURE__ */ new Error("client closed the download"));
9444
+ return new Promise((resolve, reject) => {
9445
+ const cleanup = () => {
9446
+ res.off("finish", onFinish);
9447
+ res.off("close", onClose);
9448
+ };
9449
+ const onFinish = () => {
9450
+ cleanup();
9451
+ resolve();
9452
+ };
9453
+ const onClose = () => {
9454
+ cleanup();
9455
+ reject(/* @__PURE__ */ new Error("client closed the download"));
9456
+ };
9457
+ res.once("finish", onFinish);
9458
+ res.once("close", onClose);
9459
+ });
9460
+ }
9461
+ function replaceExtension(name, extension) {
9462
+ const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
9463
+ const dot = name.lastIndexOf(".");
9464
+ return dot <= slash ? name + "." + extension : name.slice(0, dot) + "." + extension;
9465
+ }
9466
+ function isClosed(zip, err) {
9467
+ return zip.isClosed || err instanceof Error && err.message === "client closed the download";
9468
+ }
9469
+ function sameOrigin$7(req) {
9470
+ const origin = req.headers.origin;
9471
+ const host = req.headers.host;
9472
+ if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
9473
+ return true;
9474
+ }
9475
+ async function readBody$5(req, limit) {
9476
+ const chunks = [];
9477
+ let bytes = 0;
9478
+ for await (const chunk of req) {
9479
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
9480
+ bytes += buffer.byteLength;
9481
+ if (bytes > limit) throw new Error("request too large");
9482
+ chunks.push(buffer);
9483
+ }
9484
+ return Buffer.concat(chunks).toString("utf8");
9485
+ }
9486
+ function jsonError$7(res, status, code) {
9487
+ res.writeHead(status, {
9488
+ "content-type": "application/json; charset=utf-8",
9489
+ "cache-control": "no-store"
9490
+ });
9491
+ res.end(JSON.stringify({ error: code }));
9492
+ }
9493
+ /** The cache layout is <dir>/<2 hex>/<variant hash>; two levels are enough. */
9494
+ const MAX_DEPTH = 2;
9495
+ /** Resolve the default cache root below DSH_HOME. */
9496
+ function resolveRequestImageCacheDir(home) {
9497
+ const root = home?.trim() || process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
9498
+ return join(root, "cache", "attachments", "request-images");
9499
+ }
9500
+ /** Durable attachment objects; never a pruning target, even via a broad override. */
9501
+ function durableAttachmentsRoot() {
9502
+ return join(process.env.DSH_HOME?.trim() || join(homedir(), ".dsh"), "attachments");
9503
+ }
9504
+ function comparable(path) {
9505
+ const resolved = resolve(path);
9506
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
9507
+ }
9508
+ function isWithin(path, root) {
9509
+ return path === root || path.startsWith(root + sep);
9510
+ }
9511
+ /** Resolve through junctions/symlinks, falling back to the nearest existing ancestor. */
9512
+ async function canonicalPath(path) {
9513
+ let probe = resolve(path);
9514
+ const suffix = [];
9515
+ while (true) try {
9516
+ const real = await realpath(probe);
9517
+ return comparable(join(real, ...suffix.reverse()));
9518
+ } catch {
9519
+ const parent = dirname(probe);
9520
+ if (parent === probe) return comparable(resolve(path));
9521
+ suffix.push(basename(probe));
9522
+ probe = parent;
9523
+ }
9524
+ }
9525
+ async function overlapsProtectedRoot(target, roots) {
9526
+ for (const root of roots) {
9527
+ const protectedRoot = await canonicalPath(root);
9528
+ if (target === protectedRoot || isWithin(target, protectedRoot) || isWithin(protectedRoot, target)) return true;
9529
+ }
9530
+ return false;
9531
+ }
9532
+ /**
9533
+ * Remove stale request-image variants.
9534
+ *
9535
+ * The attachment backend writes one file per (attachment, target) variant and
9536
+ * never cleans them up itself, so a gallery full of 320 px thumbnails grows it
9537
+ * without bound. Deletion is safe: variants are deterministic and regenerated
9538
+ * on demand, so removing an entry only costs one sharp pass next time.
9539
+ */
9540
+ async function pruneRequestImageCache(options) {
9541
+ const ttlMs = options.ttlMs ?? 12096e5;
9542
+ const maxBytes = options.maxBytes ?? 268435456;
9543
+ const keepMs = options.keepMs ?? 36e5;
9544
+ const now = options.now ?? Date.now();
9545
+ const protectedRoots = options.protectedRoots ?? [durableAttachmentsRoot()];
9546
+ const cacheRoot = await canonicalPath(options.dir);
9547
+ if (await overlapsProtectedRoot(cacheRoot, protectedRoots)) return {
9548
+ scanned: 0,
9549
+ removed: 0,
9550
+ freedBytes: 0,
9551
+ remainingBytes: 0
9552
+ };
9553
+ const files = [];
9554
+ await collect(options.dir, 0, files, cacheRoot);
9555
+ let remainingBytes = files.reduce((sum, file) => sum + file.bytes, 0);
9556
+ const protectedAfter = now - keepMs;
9557
+ const expiredBefore = now - ttlMs;
9558
+ const doomed = /* @__PURE__ */ new Set();
9559
+ for (const file of files) if (file.mtimeMs < expiredBefore && file.mtimeMs < protectedAfter) {
9560
+ doomed.add(file.path);
9561
+ remainingBytes -= file.bytes;
9562
+ }
9563
+ if (maxBytes > 0 && remainingBytes > maxBytes) {
9564
+ const candidates = files.filter((file) => !doomed.has(file.path) && file.mtimeMs < protectedAfter).sort((a, b) => a.mtimeMs - b.mtimeMs);
9565
+ for (const file of candidates) {
9566
+ if (remainingBytes <= maxBytes) break;
9567
+ doomed.add(file.path);
9568
+ remainingBytes -= file.bytes;
9569
+ }
9570
+ }
9571
+ let removed = 0;
9572
+ let freedBytes = 0;
9573
+ for (const file of files) {
9574
+ if (!doomed.has(file.path)) continue;
9575
+ try {
9576
+ const latest = await lstat(file.path);
9577
+ if (latest.isSymbolicLink() || !latest.isFile() || !isWithin(await canonicalPath(file.path), cacheRoot)) {
9578
+ remainingBytes += file.bytes;
9579
+ continue;
9580
+ }
9581
+ await unlink(file.path);
9582
+ removed += 1;
9583
+ freedBytes += file.bytes;
9584
+ } catch {
9585
+ remainingBytes += file.bytes;
9586
+ }
9587
+ }
9588
+ return {
9589
+ scanned: files.length,
9590
+ removed,
9591
+ freedBytes,
9592
+ remainingBytes
9593
+ };
9594
+ }
9595
+ async function collect(dir, depth, out, cacheRoot) {
9596
+ let entries;
9597
+ try {
9598
+ entries = await readdir(dir, { withFileTypes: true });
9599
+ } catch {
9600
+ return;
9601
+ }
9602
+ for (const entry of entries) {
9603
+ const path = join(dir, entry.name);
9604
+ let info;
9605
+ try {
9606
+ info = await lstat(path);
9607
+ } catch {
9608
+ continue;
9609
+ }
9610
+ if (info.isSymbolicLink()) continue;
9611
+ if (info.isDirectory()) {
9612
+ if (depth >= MAX_DEPTH) continue;
9613
+ if (!isWithin(await canonicalPath(path), cacheRoot)) continue;
9614
+ await collect(path, depth + 1, out, cacheRoot);
9615
+ continue;
9616
+ }
9617
+ if (!info.isFile()) continue;
9618
+ if (!isWithin(await canonicalPath(path), cacheRoot)) continue;
9619
+ try {
9620
+ const latest = await stat(path);
9621
+ out.push({
9622
+ path,
9623
+ bytes: latest.size,
9624
+ mtimeMs: latest.mtimeMs
9625
+ });
9626
+ } catch {}
9627
+ }
9628
+ }
9629
+ function resolveDshHome() {
9630
+ return process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
9631
+ }
9632
+ function resolveAttachmentsRoot(home = resolveDshHome()) {
9633
+ return join(home, "attachments", "v1", "objects");
9634
+ }
9635
+ async function scanTree(root) {
9636
+ try {
9637
+ const entries = await readdir(root, {
9638
+ recursive: true,
9639
+ withFileTypes: true
9640
+ });
9641
+ const files = [];
9642
+ let bytes = 0;
9643
+ for (const entry of entries) {
9644
+ if (!entry.isFile()) continue;
9645
+ const parent = entry.parentPath ?? root;
9646
+ try {
9647
+ const info = await stat(join(parent, entry.name));
9648
+ bytes += info.size;
9649
+ files.push({
9650
+ name: entry.name,
9651
+ bytes: info.size
9652
+ });
9653
+ } catch {}
9654
+ }
9655
+ return {
9656
+ count: files.length,
9657
+ bytes,
9658
+ files
9659
+ };
9660
+ } catch {
9661
+ return {
9662
+ count: 0,
9663
+ bytes: 0,
9664
+ files: []
9665
+ };
9666
+ }
9667
+ }
9668
+ let baseCache = null;
9669
+ async function collectStorageStats(attachmentIds, options = {}) {
9670
+ const now = options.now ?? Date.now();
9671
+ const ttl = options.ttlMs ?? 6e5;
9672
+ const injected = options.attachmentsRoot !== void 0;
9673
+ const attachmentsRoot = options.attachmentsRoot ?? resolveAttachmentsRoot();
9674
+ const requestImagesRoot = options.requestImagesRoot ?? resolveRequestImageCacheDir(resolveDshHome());
9675
+ if (!injected && baseCache !== null && baseCache.requestImagesRoot === requestImagesRoot && now - baseCache.at < ttl) return buildStats(baseCache.attachments, baseCache.requestImages, attachmentIds, now);
9676
+ const [attachments, requestImages] = await Promise.all([scanTree(attachmentsRoot), scanTree(requestImagesRoot)]);
9677
+ if (!injected) baseCache = {
9678
+ at: now,
9679
+ attachments,
9680
+ requestImages,
9681
+ requestImagesRoot
9682
+ };
9683
+ return buildStats(attachments, requestImages, attachmentIds, now);
9684
+ }
9685
+ function buildStats(attachments, requestImages, attachmentIds, now) {
9686
+ const refs = /* @__PURE__ */ new Set();
9687
+ for (const id of attachmentIds) {
9688
+ const value = String(id);
9689
+ refs.add(value);
9690
+ refs.add(value.replace(/^sha256:/, ""));
9691
+ }
9692
+ const orphans = attachments.files.filter((file) => !refs.has(file.name) && !refs.has("sha256:" + file.name)).map((file) => ({
9693
+ id: "sha256:" + file.name,
9694
+ bytes: file.bytes
9695
+ }));
9696
+ return {
9697
+ attachments: {
9698
+ count: attachments.count,
9699
+ bytes: attachments.bytes
9700
+ },
9701
+ requestImages: {
9702
+ count: requestImages.count,
9703
+ bytes: requestImages.bytes
9704
+ },
9705
+ orphans: {
9706
+ count: orphans.length,
9707
+ bytes: orphans.reduce((sum, entry) => sum + entry.bytes, 0),
9708
+ ids: orphans.map((entry) => entry.id)
9709
+ },
9710
+ scannedAt: now
9711
+ };
9712
+ }
9713
+ /** POST {attachmentIds: string[]} -> read-only usage numbers. */
9714
+ async function serveStorageStats(req, res, deps = {}) {
9715
+ if (req.method !== "POST") return jsonError$6(res, 405, "method-not-allowed");
9716
+ if (!sameOrigin$6(req)) return jsonError$6(res, 403, "origin-rejected");
9717
+ let body;
9718
+ try {
9719
+ body = await readBody$4(req, 2097152);
9720
+ } catch {
9721
+ return jsonError$6(res, 413, "request-too-large");
9722
+ }
9723
+ let parsed;
9724
+ try {
9725
+ parsed = JSON.parse(body);
9726
+ } catch {
9727
+ return jsonError$6(res, 400, "invalid-request");
9728
+ }
9729
+ const ids = parsed?.attachmentIds;
9730
+ if (!Array.isArray(ids)) return jsonError$6(res, 400, "invalid-request");
9731
+ const stats = await collectStorageStats(ids.filter((id) => typeof id === "string"), deps.requestImagesRoot === void 0 ? {} : { requestImagesRoot: deps.requestImagesRoot });
9732
+ res.writeHead(200, {
9733
+ "content-type": "application/json; charset=utf-8",
9734
+ "cache-control": "no-store"
9735
+ });
9736
+ res.end(JSON.stringify(stats));
8281
9737
  }
8282
- function record$4(value) {
8283
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
9738
+ function sameOrigin$6(req) {
9739
+ const origin = req.headers.origin;
9740
+ const host = req.headers.host;
9741
+ if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
9742
+ return true;
8284
9743
  }
8285
- async function readBody$4(req) {
9744
+ async function readBody$4(req, limit) {
8286
9745
  const chunks = [];
8287
9746
  let bytes = 0;
8288
9747
  for await (const chunk of req) {
8289
9748
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8290
9749
  bytes += buffer.byteLength;
8291
- if (bytes > MAX_BODY_BYTES$2) throw new Error("request too large");
9750
+ if (bytes > limit) throw new Error("request too large");
8292
9751
  chunks.push(buffer);
8293
9752
  }
8294
9753
  return Buffer.concat(chunks).toString("utf8");
8295
9754
  }
8296
- function jsonError$5(res, status, code) {
9755
+ function jsonError$6(res, status, code) {
8297
9756
  res.writeHead(status, {
8298
- "content-type": "application/json",
9757
+ "content-type": "application/json; charset=utf-8",
8299
9758
  "cache-control": "no-store"
8300
9759
  });
8301
9760
  res.end(JSON.stringify({ error: code }));
8302
9761
  }
8303
9762
  //#endregion
8304
- //#region lib/types/import-route.js
8305
- const MAX_IMAGES_PER_REQUEST = 8;
8306
- const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
8307
- /** base64 inflates bytes by 4/3; allow one full batch plus JSON overhead. */
8308
- function bodyLimit(maxImageBytes) {
8309
- return MAX_IMAGES_PER_REQUEST * Math.ceil(maxImageBytes * 1.4) + 4096;
9763
+ //#region lib/types/vault-registry.js
9764
+ /** Persistent per-profile vault references so one client cannot delete another client's originals. */
9765
+ const REGISTRY_FILE = ".refs.json";
9766
+ const PROFILE_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
9767
+ function isValidVaultProfileId(value) {
9768
+ return typeof value === "string" && PROFILE_PATTERN.test(value);
8310
9769
  }
8311
- async function serveImport(req, res, deps) {
8312
- if (req.method !== "POST") return jsonError$4(res, 405, "method-not-allowed");
8313
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$4(res, 415, "json-required");
8314
- const origin = req.headers.origin;
8315
- const host = req.headers.host;
8316
- if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$4(res, 403, "origin-rejected");
9770
+ function registryPath(dir) {
9771
+ return join(dir, REGISTRY_FILE);
9772
+ }
9773
+ function normalizeRegistry(value) {
9774
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
9775
+ const raw = value;
9776
+ if (raw.version !== 1 || typeof raw.profiles !== "object" || raw.profiles === null || Array.isArray(raw.profiles)) return void 0;
9777
+ const profiles = Object.create(null);
9778
+ for (const [profileId, profile] of Object.entries(raw.profiles)) {
9779
+ if (!isValidVaultProfileId(profileId) || typeof profile !== "object" || profile === null || Array.isArray(profile)) return void 0;
9780
+ const { keys, updatedAt } = profile;
9781
+ if (!Array.isArray(keys) || !keys.every((key) => typeof key === "string" && isValidVaultKey(key))) return void 0;
9782
+ if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt < 0) return void 0;
9783
+ profiles[profileId] = {
9784
+ keys: Array.from(new Set(keys)),
9785
+ updatedAt
9786
+ };
9787
+ }
9788
+ return {
9789
+ version: 1,
9790
+ profiles
9791
+ };
9792
+ }
9793
+ async function loadVaultRegistry(dir) {
9794
+ try {
9795
+ const registry = normalizeRegistry(JSON.parse(await readFile(registryPath(dir), "utf8")));
9796
+ return registry === void 0 ? { status: "corrupt" } : {
9797
+ status: "ok",
9798
+ registry
9799
+ };
9800
+ } catch (err) {
9801
+ if (err.code === "ENOENT") return { status: "missing" };
9802
+ return { status: "corrupt" };
9803
+ }
9804
+ }
9805
+ const queues = /* @__PURE__ */ new Map();
9806
+ /** Canonicalize existing ancestors too, so aliases share a lock before creation. */
9807
+ async function vaultTransactionKey(dir) {
9808
+ let parent = resolve(dir);
9809
+ const suffix = [];
9810
+ while (true) try {
9811
+ const canonical = join(await realpath(parent), ...suffix);
9812
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
9813
+ } catch (error) {
9814
+ if (error.code !== "ENOENT" || dirname(parent) === parent) throw error;
9815
+ suffix.unshift(basename(parent));
9816
+ parent = dirname(parent);
9817
+ }
9818
+ }
9819
+ /** Serialize reference mutations and deletion together within this host. */
9820
+ async function inVaultTransaction(dir, run) {
9821
+ const key = await vaultTransactionKey(dir);
9822
+ const operation = (queues.get(key) ?? Promise.resolve()).then(run);
9823
+ const tail = operation.then(() => void 0, () => void 0);
9824
+ queues.set(key, tail);
9825
+ tail.then(() => {
9826
+ if (queues.get(key) === tail) queues.delete(key);
9827
+ });
9828
+ return operation;
9829
+ }
9830
+ /** Persist a complete profile only after all newly claimed originals exist. */
9831
+ async function writeVaultProfile(dir, profileId, keys, loaded) {
9832
+ if (loaded.status === "corrupt" || !isValidVaultProfileId(profileId) || profileId === "legacy-vault" || !keys.every(isValidVaultKey)) return void 0;
9833
+ const registry = loaded.registry ?? {
9834
+ version: 1,
9835
+ profiles: {}
9836
+ };
9837
+ const accepted = Array.from(new Set(keys));
9838
+ const previous = new Set(registry.profiles[profileId]?.keys ?? []);
9839
+ let existing;
9840
+ if (loaded.status === "missing" || accepted.some((key) => !previous.has(key))) {
9841
+ existing = await listVaultKeys({
9842
+ enabled: true,
9843
+ dir
9844
+ });
9845
+ const available = new Set(existing);
9846
+ if (accepted.some((key) => !available.has(key))) return void 0;
9847
+ }
9848
+ if (loaded.status === "missing") {
9849
+ const legacy = existing.filter((key) => !accepted.includes(key));
9850
+ if (legacy.length > 0) registry.profiles["legacy-vault"] = {
9851
+ keys: legacy,
9852
+ updatedAt: 0
9853
+ };
9854
+ }
9855
+ Object.defineProperty(registry.profiles, profileId, {
9856
+ value: {
9857
+ keys: accepted,
9858
+ updatedAt: Date.now()
9859
+ },
9860
+ enumerable: true,
9861
+ writable: true,
9862
+ configurable: true
9863
+ });
9864
+ await persistRegistry(dir, registry);
9865
+ return registry;
9866
+ }
9867
+ async function persistRegistry(dir, registry) {
9868
+ await mkdir(dir, { recursive: true });
9869
+ const target = registryPath(dir);
9870
+ const temporary = target + ".tmp-" + randomUUID();
9871
+ try {
9872
+ await writeFile(temporary, JSON.stringify(registry, null, 2), "utf8");
9873
+ await rename(temporary, target);
9874
+ } finally {
9875
+ await unlink(temporary).catch(() => void 0);
9876
+ }
9877
+ }
9878
+ /** Store and claim an import in the same transaction used by explicit deletion. */
9879
+ function saveImportedOriginal(config, input, profileId) {
9880
+ if (!config.enabled) return Promise.resolve(void 0);
9881
+ return inVaultTransaction(config.dir, async () => {
9882
+ if (profileId !== void 0 && (!isValidVaultProfileId(profileId) || profileId === "legacy-vault")) throw new Error("invalid-vault-profile");
9883
+ const loaded = await loadVaultRegistry(config.dir);
9884
+ if (loaded.status === "corrupt") throw new Error("registry-unavailable");
9885
+ const registry = loaded.registry ?? {
9886
+ version: 1,
9887
+ profiles: {}
9888
+ };
9889
+ if (loaded.status === "missing") {
9890
+ const legacy = await listVaultKeys(config);
9891
+ if (legacy.length > 0) registry.profiles["legacy-vault"] = {
9892
+ keys: legacy,
9893
+ updatedAt: 0
9894
+ };
9895
+ }
9896
+ const saved = await saveOriginal(config, input);
9897
+ if (saved === void 0) return void 0;
9898
+ const owner = profileId ?? "legacy-vault";
9899
+ Object.defineProperty(registry.profiles, owner, {
9900
+ value: {
9901
+ keys: [.../* @__PURE__ */ new Set([...registry.profiles[owner]?.keys ?? [], saved.key])],
9902
+ updatedAt: Date.now()
9903
+ },
9904
+ enumerable: true,
9905
+ configurable: true,
9906
+ writable: true
9907
+ });
9908
+ await persistRegistry(config.dir, registry);
9909
+ return saved;
9910
+ });
9911
+ }
9912
+ /**
9913
+ * Merge one profile's complete key set into the persistent registry.
9914
+ *
9915
+ * On first creation, pre-existing vault files that the syncing profile does
9916
+ * not already reference are placed in a reserved legacy profile, so a browser
9917
+ * that has not opened yet cannot lose an original. Once a profile syncs a
9918
+ * legacy key, the unknown legacy owners remain protected too.
9919
+ */
9920
+ function updateVaultRegistry(dir, profileId, keys) {
9921
+ return inVaultTransaction(dir, async () => {
9922
+ const loaded = await loadVaultRegistry(dir);
9923
+ return writeVaultProfile(dir, profileId, [...loaded.registry?.profiles[profileId]?.keys ?? [], ...keys], loaded);
9924
+ }).catch(() => void 0);
9925
+ }
9926
+ /** Remove explicitly owned keys using the same transaction as reference sync. */
9927
+ function deleteUnreferencedOriginals(config, profileId, keys, deleteKeys) {
9928
+ return inVaultTransaction(config.dir, async () => {
9929
+ const loaded = await loadVaultRegistry(config.dir);
9930
+ if (loaded.status !== "ok" || loaded.registry === void 0) return void 0;
9931
+ const previouslyOwned = new Set(loaded.registry.profiles[profileId]?.keys ?? []);
9932
+ const removedKeys = new Set(deleteKeys);
9933
+ const remaining = [...previouslyOwned].filter((key) => !removedKeys.has(key));
9934
+ const registry = await writeVaultProfile(config.dir, profileId, [...remaining, ...keys], loaded);
9935
+ if (registry === void 0) return void 0;
9936
+ const referenced = new Set(unionVaultKeys(registry));
9937
+ return deleteVaultKeys(config, deleteKeys.filter((key) => previouslyOwned.has(key) && !referenced.has(key)));
9938
+ }).catch(() => void 0);
9939
+ }
9940
+ function unionVaultKeys(registry) {
9941
+ const keys = /* @__PURE__ */ new Set();
9942
+ for (const profile of Object.values(registry.profiles)) for (const key of profile.keys) keys.add(key);
9943
+ return Array.from(keys);
9944
+ }
9945
+ //#endregion
9946
+ //#region lib/types/vault-route.js
9947
+ async function serveVault(req, res, config) {
9948
+ if (req.method !== "GET") return jsonError$5(res, 405, "method-not-allowed");
9949
+ if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
9950
+ const params = new URL(req.url ?? "/", "http://x").searchParams;
9951
+ const key = params.get("key") ?? "";
9952
+ if (!isValidVaultKey(key)) return jsonError$5(res, 400, "invalid-key");
9953
+ const original = await readOriginal(config, key, params.get("mediaType") ?? void 0);
9954
+ if (original === void 0) return jsonError$5(res, 404, "not-found");
9955
+ const etag = "\"sha256:" + key + "\"";
9956
+ const cacheControl = "private, max-age=31536000, immutable";
9957
+ const noneMatch = req.headers["if-none-match"];
9958
+ if (typeof noneMatch === "string" && noneMatch.split(",").some((value) => value.trim() === etag)) {
9959
+ res.writeHead(304, {
9960
+ "cache-control": cacheControl,
9961
+ etag
9962
+ });
9963
+ res.end();
9964
+ return;
9965
+ }
9966
+ res.writeHead(200, {
9967
+ "content-type": original.mediaType,
9968
+ "content-length": String(original.data.byteLength),
9969
+ "cache-control": cacheControl,
9970
+ etag,
9971
+ "x-content-type-options": "nosniff"
9972
+ });
9973
+ res.end(original.data);
9974
+ }
9975
+ /** Sync one browser profile's references; never deletes originals. */
9976
+ async function serveVaultRefs(req, res, config) {
9977
+ if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
9978
+ if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
9979
+ const payload = await readJson(req, res);
9980
+ if (payload === void 0) return;
9981
+ const profileId = payload.profileId;
9982
+ const keys = payload.keys;
9983
+ if (!isValidVaultProfileId(profileId)) {
9984
+ jsonOk(res, {
9985
+ removed: 0,
9986
+ bytes: 0
9987
+ });
9988
+ return;
9989
+ }
9990
+ if (!validKeys(keys) || profileId === "legacy-vault") return jsonError$5(res, 400, "invalid-request");
9991
+ const registry = await updateVaultRegistry(config.dir, profileId, keys);
9992
+ if (registry === void 0) return jsonError$5(res, 409, "registry-unavailable");
9993
+ jsonOk(res, {
9994
+ removed: 0,
9995
+ bytes: 0,
9996
+ profiles: Object.keys(registry.profiles).length
9997
+ });
9998
+ }
9999
+ /** Explicit gallery deletion: remove only keys no profile still references. */
10000
+ async function serveVaultDelete(req, res, config) {
10001
+ if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
10002
+ if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
10003
+ const payload = await readJson(req, res);
10004
+ if (payload === void 0) return;
10005
+ const profileId = payload.profileId;
10006
+ const keys = payload.keys;
10007
+ const deleteKeys = payload.deleteKeys;
10008
+ if (!isValidVaultProfileId(profileId) || profileId === "legacy-vault") return jsonError$5(res, 400, "invalid-profile");
10009
+ if (!validKeys(keys) || !validKeys(deleteKeys) || payload.confirm !== true) return jsonError$5(res, 400, "invalid-request");
10010
+ const stats = await deleteUnreferencedOriginals(config, profileId, keys, deleteKeys);
10011
+ if (stats === void 0) return jsonError$5(res, 409, "registry-unavailable");
10012
+ jsonOk(res, stats);
10013
+ }
10014
+ function validKeys(value) {
10015
+ return Array.isArray(value) && value.every((key) => typeof key === "string" && isValidVaultKey(key));
10016
+ }
10017
+ async function readJson(req, res) {
8317
10018
  let body;
8318
10019
  try {
8319
- body = JSON.parse(await readBody$3(req, bodyLimit(deps.maxImageBytes)));
10020
+ body = await readBody$3(req, 4194304);
8320
10021
  } catch {
8321
- return jsonError$4(res, 400, "invalid-request");
10022
+ jsonError$5(res, 413, "request-too-large");
10023
+ return;
8322
10024
  }
8323
- const images = typeof body === "object" && body !== null ? body.images : void 0;
8324
- if (!Array.isArray(images) || images.length === 0 || images.length > MAX_IMAGES_PER_REQUEST) return jsonError$4(res, 400, "invalid-image-count");
8325
- const saved = [];
8326
- const failures = [];
8327
- for (const [index, item] of images.entries()) {
8328
- const record = typeof item === "object" && item !== null ? item : void 0;
8329
- if (record === void 0 || typeof record.data !== "string" || typeof record.mediaType !== "string") {
8330
- failures.push({
8331
- index,
8332
- error: "invalid-item"
8333
- });
8334
- continue;
8335
- }
8336
- if (!deps.mediaTypes.includes(record.mediaType)) {
8337
- failures.push({
8338
- index,
8339
- error: `unsupported-media-type: ${record.mediaType}`
8340
- });
8341
- continue;
8342
- }
8343
- if (!BASE64_PATTERN.test(record.data)) {
8344
- failures.push({
8345
- index,
8346
- error: "invalid-base64"
8347
- });
8348
- continue;
8349
- }
8350
- const bytes = Buffer.from(record.data, "base64");
8351
- if (bytes.byteLength === 0 || bytes.byteLength > deps.maxImageBytes) {
8352
- failures.push({
8353
- index,
8354
- error: `size-out-of-range (max ${String(deps.maxImageBytes)} bytes)`
8355
- });
8356
- continue;
8357
- }
8358
- try {
8359
- const attachment = await deps.saveImage({
8360
- data: bytes,
8361
- mediaType: record.mediaType,
8362
- ...typeof record.name === "string" && record.name.length > 0 ? { name: record.name } : {}
8363
- });
8364
- saved.push({ attachment });
8365
- } catch {
8366
- failures.push({
8367
- index,
8368
- error: "save-failed"
8369
- });
10025
+ try {
10026
+ const parsed = JSON.parse(body);
10027
+ if (typeof parsed !== "object" || parsed === null) {
10028
+ jsonError$5(res, 400, "invalid-request");
10029
+ return;
8370
10030
  }
10031
+ return parsed;
10032
+ } catch {
10033
+ jsonError$5(res, 400, "invalid-request");
10034
+ return;
8371
10035
  }
10036
+ }
10037
+ function jsonOk(res, value) {
8372
10038
  res.writeHead(200, {
8373
- "content-type": "application/json",
10039
+ "content-type": "application/json; charset=utf-8",
8374
10040
  "cache-control": "no-store"
8375
10041
  });
8376
- res.end(JSON.stringify({
8377
- images: saved,
8378
- failures
8379
- }));
10042
+ res.end(JSON.stringify(value));
10043
+ }
10044
+ function sameOrigin$5(req) {
10045
+ const origin = req.headers.origin;
10046
+ const host = req.headers.host;
10047
+ if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
10048
+ return true;
8380
10049
  }
8381
- async function readBody$3(req, maxBytes) {
10050
+ async function readBody$3(req, limit) {
8382
10051
  const chunks = [];
8383
10052
  let bytes = 0;
8384
10053
  for await (const chunk of req) {
8385
10054
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8386
10055
  bytes += buffer.byteLength;
8387
- if (bytes > maxBytes) throw new Error("request-too-large");
10056
+ if (bytes > limit) throw new Error("request too large");
8388
10057
  chunks.push(buffer);
8389
10058
  }
8390
10059
  return Buffer.concat(chunks).toString("utf8");
8391
10060
  }
10061
+ function jsonError$5(res, status, code) {
10062
+ res.writeHead(status, {
10063
+ "content-type": "application/json; charset=utf-8",
10064
+ "cache-control": "no-store"
10065
+ });
10066
+ res.end(JSON.stringify({ error: code }));
10067
+ }
10068
+ //#endregion
10069
+ //#region lib/types/thumb-route.js
10070
+ const THUMB = {
10071
+ defaultWidth: 320,
10072
+ minWidth: 64,
10073
+ maxWidth: 1024,
10074
+ /** Generous for a 320 px tile, so the quality ladder stays at its top rung. */
10075
+ maxBytes: 98304,
10076
+ unavailableCode: "thumbnail-unavailable"
10077
+ };
10078
+ /**
10079
+ * A 1600 px long edge keeps lightbox decoding small (a 33.5 MP original costs
10080
+ * roughly 130 MB as RGBA) while staying sharp on a desktop window.
10081
+ */
10082
+ const PREVIEW = {
10083
+ defaultWidth: 1600,
10084
+ minWidth: 256,
10085
+ maxWidth: 2048,
10086
+ maxBytes: 524288,
10087
+ unavailableCode: "preview-unavailable"
10088
+ };
10089
+ /**
10090
+ * Serve one downscaled, cached thumbnail.
10091
+ *
10092
+ * The attachment backend already derives deterministic request images (resize by
10093
+ * long edge + JPEG/WebP quality ladder) and caches them on disk, so the gallery
10094
+ * can render a tile without transferring the original to the renderer at all.
10095
+ */
10096
+ async function serveThumb(req, res, deps) {
10097
+ return serveVariant(req, res, deps, THUMB);
10098
+ }
10099
+ /** Serve the mid-size variant used by the lightbox preview. */
10100
+ async function servePreview(req, res, deps) {
10101
+ return serveVariant(req, res, deps, PREVIEW);
10102
+ }
10103
+ async function serveVariant(req, res, deps, config) {
10104
+ if (req.method !== "GET") return jsonError$4(res, 405, "method-not-allowed");
10105
+ if (!sameOrigin$4(req)) return jsonError$4(res, 403, "origin-rejected");
10106
+ const url = new URL(req.url ?? "/", "http://x");
10107
+ const rawRef = url.searchParams.get("ref");
10108
+ const attachment = rawRef === null ? void 0 : decodeImageRef(rawRef, parseImageAttachmentRef);
10109
+ if (attachment === void 0) return jsonError$4(res, 400, "invalid-attachment");
10110
+ const requested = Number(url.searchParams.get("w") ?? config.defaultWidth);
10111
+ const width = Number.isFinite(requested) ? Math.min(config.maxWidth, Math.max(config.minWidth, Math.round(requested))) : config.defaultWidth;
10112
+ try {
10113
+ const variant = await deps.readImageRequest(attachment, {
10114
+ width,
10115
+ height: width,
10116
+ maxBytes: config.maxBytes
10117
+ });
10118
+ const etag = "\"" + String(variant.variantId) + "\"";
10119
+ const headers = {
10120
+ "cache-control": "private, max-age=31536000, immutable",
10121
+ etag
10122
+ };
10123
+ const ifNoneMatch = req.headers["if-none-match"];
10124
+ if (typeof ifNoneMatch === "string" && ifNoneMatch.split(",").some((value) => value.trim() === etag)) {
10125
+ res.writeHead(304, headers);
10126
+ res.end();
10127
+ return;
10128
+ }
10129
+ res.writeHead(200, {
10130
+ "content-type": variant.mediaType,
10131
+ "content-length": String(variant.data.byteLength),
10132
+ "x-content-type-options": "nosniff",
10133
+ ...headers
10134
+ });
10135
+ res.end(variant.data);
10136
+ } catch {
10137
+ jsonError$4(res, 404, config.unavailableCode);
10138
+ }
10139
+ }
10140
+ function sameOrigin$4(req) {
10141
+ const origin = req.headers.origin;
10142
+ const host = req.headers.host;
10143
+ if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
10144
+ return true;
10145
+ }
8392
10146
  function jsonError$4(res, status, code) {
8393
10147
  res.writeHead(status, {
8394
10148
  "content-type": "application/json",
@@ -8535,26 +10289,66 @@ async function editOpenAICompatibleImage(input) {
8535
10289
  body: form
8536
10290
  }), input.apiKey), "openai", input);
8537
10291
  }
8538
- const form = new FormData();
8539
- const imageField = input.sourceImages.length > 1 ? "image[]" : "image";
8540
- input.sourceImages.forEach((sourceImage, index) => {
8541
- const uploadBytes = new Uint8Array(sourceImage.data);
8542
- const blob = new Blob([uploadBytes], { type: sourceImage.mediaType });
8543
- const filename = `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`;
8544
- form.append(imageField, blob, filename);
8545
- });
8546
- form.append("prompt", input.prompt);
8547
- form.append("model", input.model);
8548
- if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
8549
- if (input.quality !== void 0) form.append("quality", input.quality);
8550
- for (const [key, value] of Object.entries(input.extraBody ?? {})) form.append(key, String(value));
8551
- return parseImageResponse(await guardedFetch("openai image edit request", input.signal, () => fetch(imageEndpoint$1(input.baseURL, "edits"), {
10292
+ const responseFormat = input.editResponseFormat ?? "url";
10293
+ const explicitResponseFormat = Object.prototype.hasOwnProperty.call(input.extraBody ?? {}, "response_format");
10294
+ const buildEditForm = (includeResponseFormat) => {
10295
+ const form = new FormData();
10296
+ const imageField = input.sourceImages.length > 1 ? "image[]" : "image";
10297
+ input.sourceImages.forEach((sourceImage, index) => {
10298
+ const uploadBytes = new Uint8Array(sourceImage.data);
10299
+ const blob = new Blob([uploadBytes], { type: sourceImage.mediaType });
10300
+ const filename = `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`;
10301
+ form.append(imageField, blob, filename);
10302
+ });
10303
+ form.append("prompt", input.prompt);
10304
+ form.append("model", input.model);
10305
+ if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
10306
+ if (input.quality !== void 0) form.append("quality", input.quality);
10307
+ if (includeResponseFormat && !explicitResponseFormat) form.append("response_format", "b64_json");
10308
+ for (const [key, value] of Object.entries(input.extraBody ?? {})) {
10309
+ if (value === void 0) continue;
10310
+ form.append(key, String(value));
10311
+ }
10312
+ return form;
10313
+ };
10314
+ const sendEdit = (form) => guardedFetch("openai image edit request", input.signal, () => fetch(imageEndpoint$1(input.baseURL, "edits"), {
8552
10315
  method: "POST",
8553
10316
  redirect: "error",
8554
10317
  signal: input.signal,
8555
10318
  headers: { authorization: `Bearer ${input.apiKey}` },
8556
10319
  body: form
8557
- }), input.apiKey), "openai", input);
10320
+ }), input.apiKey);
10321
+ let response = await sendEdit(buildEditForm(responseFormat !== "url"));
10322
+ if (responseFormat === "auto" && !explicitResponseFormat && (response.status === 400 || response.status === 422)) {
10323
+ const text = await readBoundedText$1(response, ERROR_LIMIT$1);
10324
+ if (!rejectsResponseFormat(text)) throw new Error(`openai image request failed (${response.status}): ${redactSecrets(text, input.apiKey).slice(0, ERROR_LIMIT$1)}`);
10325
+ input.onResponseFormatRetry?.(response.status);
10326
+ response = await sendEdit(buildEditForm(false));
10327
+ }
10328
+ return parseImageResponse(response, "openai", input);
10329
+ }
10330
+ function rejectsResponseFormat(text) {
10331
+ let message = text;
10332
+ try {
10333
+ const body = JSON.parse(text);
10334
+ if (typeof body === "string") message = body;
10335
+ else if (body !== null && typeof body === "object") {
10336
+ const error = typeof body.error === "object" && body.error !== null ? body.error : void 0;
10337
+ if (typeof error?.param === "string") return error.param === "response_format";
10338
+ if (Array.isArray(body.detail)) return body.detail.length > 0 && body.detail.every((entry) => {
10339
+ const loc = entry !== null && typeof entry === "object" ? entry.loc : void 0;
10340
+ return Array.isArray(loc) && loc.includes("response_format");
10341
+ });
10342
+ message = [
10343
+ typeof body.error === "string" ? body.error : void 0,
10344
+ error?.message,
10345
+ error?.code,
10346
+ body.message,
10347
+ body.detail
10348
+ ].filter((value) => typeof value === "string").join(" ");
10349
+ }
10350
+ } catch {}
10351
+ return /(?:response[_ -]?format|b64_json)/i.test(message) && /(?:unsupported|not\s+(?:support|allowed|permitted|accepted|implemented)|unknown|unrecognized|unexpected|invalid|extra\s+(?:field|input)|不支持|不允许|未知|无效|未识别)/i.test(message);
8558
10352
  }
8559
10353
  async function parseImageResponse(response, provider, input) {
8560
10354
  const text = await readBoundedText$1(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT$1);
@@ -8614,17 +10408,19 @@ async function downloadImage$1(url, provider, input) {
8614
10408
  mediaType: detectImageMediaType(data) ?? imageMediaType$2(parsed.mediaType) ?? "image/png"
8615
10409
  };
8616
10410
  }
8617
- const sameOrigin = new URL(url).origin === new URL(input.baseURL).origin;
8618
- let response = await guardedFetch(`${provider} image download`, input.signal, () => fetch(url, {
8619
- redirect: "follow",
8620
- signal: input.signal,
8621
- ...input.apiKey === void 0 || !sameOrigin ? {} : { headers: { authorization: `Bearer ${input.apiKey}` } }
8622
- }), input.apiKey);
8623
- if (!response.ok && input.apiKey !== void 0 && sameOrigin) response = await guardedFetch(`${provider} image download`, input.signal, () => fetch(url, {
8624
- redirect: "follow",
8625
- signal: input.signal
8626
- }), input.apiKey);
8627
- if (!response.ok) throw new Error(`${provider} image download failed (${response.status})`);
10411
+ const candidates = imageDownloadCandidates(url, input.baseURL, provider);
10412
+ let response;
10413
+ let failure;
10414
+ for (const candidate of candidates) try {
10415
+ response = await requestImageCandidate(candidate, provider, input);
10416
+ if (response.ok) break;
10417
+ failure = /* @__PURE__ */ new Error(`${provider} image download failed (${response.status})`);
10418
+ await response.body?.cancel().catch(() => void 0);
10419
+ response = void 0;
10420
+ } catch (err) {
10421
+ failure = err;
10422
+ }
10423
+ if (response === void 0) throw failure instanceof Error ? failure : /* @__PURE__ */ new Error(`${provider} image download failed`);
8628
10424
  const data = await readBoundedBytes$1(response, input.maxBytes);
8629
10425
  const mediaType = detectImageMediaType(data) ?? imageMediaType$2(response.headers.get("content-type"));
8630
10426
  if (mediaType === void 0) throw new Error(`${provider} image download returned unsupported content type`);
@@ -8633,6 +10429,34 @@ async function downloadImage$1(url, provider, input) {
8633
10429
  mediaType
8634
10430
  };
8635
10431
  }
10432
+ function imageDownloadCandidates(url, baseURL, provider) {
10433
+ const candidates = [];
10434
+ try {
10435
+ candidates.push(new URL(url, baseURL.endsWith("/") ? baseURL : baseURL + "/").toString());
10436
+ } catch {}
10437
+ if (url.startsWith("/")) try {
10438
+ const stripped = new URL(baseURL).origin + url.replace(/^\/v1(?=\/)/, "");
10439
+ if (!candidates.includes(stripped)) candidates.push(stripped);
10440
+ } catch {}
10441
+ if (candidates.length === 0) throw new Error(provider + " image download URL is not absolute");
10442
+ return candidates;
10443
+ }
10444
+ async function requestImageCandidate(candidate, provider, input) {
10445
+ const sameOrigin = new URL(candidate).origin === new URL(input.baseURL).origin;
10446
+ let response = await guardedFetch(provider + " image download", input.signal, () => fetch(candidate, {
10447
+ redirect: "follow",
10448
+ signal: input.signal,
10449
+ ...input.apiKey === void 0 || !sameOrigin ? {} : { headers: { authorization: "Bearer " + input.apiKey } }
10450
+ }), input.apiKey);
10451
+ if (!response.ok && input.apiKey !== void 0 && sameOrigin) {
10452
+ await response.body?.cancel().catch(() => void 0);
10453
+ response = await guardedFetch(provider + " image download", input.signal, () => fetch(candidate, {
10454
+ redirect: "follow",
10455
+ signal: input.signal
10456
+ }), input.apiKey);
10457
+ }
10458
+ return response;
10459
+ }
8636
10460
  function parseDataUrl(value) {
8637
10461
  const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.trim());
8638
10462
  return match?.[1] !== void 0 && match[2] !== void 0 ? {
@@ -8675,6 +10499,9 @@ async function readBoundedBytes$1(response, maxBytes) {
8675
10499
  if (bytes > maxBytes) throw new Error(`Image response exceeded the ${String(maxBytes)} byte limit`);
8676
10500
  chunks.push(next.value);
8677
10501
  }
10502
+ } catch (error) {
10503
+ await reader.cancel().catch(() => void 0);
10504
+ throw error;
8678
10505
  } finally {
8679
10506
  reader.releaseLock();
8680
10507
  }
@@ -8760,9 +10587,15 @@ async function downloadImage(url, input) {
8760
10587
  redirect: "follow",
8761
10588
  signal: input.signal
8762
10589
  }), input.apiKey);
8763
- if (!response.ok) throw new Error(`seedream image download failed (${response.status})`);
10590
+ if (!response.ok) {
10591
+ await response.body?.cancel().catch(() => void 0);
10592
+ throw new Error(`seedream image download failed (${response.status})`);
10593
+ }
8764
10594
  const mediaType = imageMediaType$1(response.headers.get("content-type"));
8765
- if (mediaType === void 0) throw new Error("seedream image download returned unsupported content type");
10595
+ if (mediaType === void 0) {
10596
+ await response.body?.cancel().catch(() => void 0);
10597
+ throw new Error("seedream image download returned unsupported content type");
10598
+ }
8766
10599
  return {
8767
10600
  data: await readBoundedBytes(response, input.maxBytes),
8768
10601
  mediaType
@@ -8795,6 +10628,9 @@ async function readBoundedBytes(response, maxBytes) {
8795
10628
  if (bytes > maxBytes) throw new Error(`Image response exceeded the ${String(maxBytes)} byte limit`);
8796
10629
  chunks.push(next.value);
8797
10630
  }
10631
+ } catch (error) {
10632
+ await reader.cancel().catch(() => void 0);
10633
+ throw error;
8798
10634
  } finally {
8799
10635
  reader.releaseLock();
8800
10636
  }
@@ -29124,6 +30960,13 @@ function apply(ctx, config = {}) {
29124
30960
  if (revision === imageContextRevision) configuredImageProviders = providers;
29125
30961
  } catch {}
29126
30962
  };
30963
+ const vaultConfig = () => {
30964
+ const config = current();
30965
+ return {
30966
+ enabled: config.vaultEnabled !== false,
30967
+ dir: resolveVaultPath(config.vaultDir ?? "")
30968
+ };
30969
+ };
29127
30970
  const settingsHealth = installImageSettings(ctx, current(), {
29128
30971
  setSource: (source) => {
29129
30972
  current = () => migrateOpenAICompatConfig(plainConfig(source()));
@@ -29132,6 +30975,33 @@ function apply(ctx, config = {}) {
29132
30975
  refreshImageToolContext();
29133
30976
  }
29134
30977
  });
30978
+ ctx.effect(() => {
30979
+ const run = () => {
30980
+ const config = current();
30981
+ if (config.requestCachePrune === false) return;
30982
+ const ttlDays = config.requestCacheTtlDays ?? 14;
30983
+ const maxBytes = config.requestCacheMaxBytes ?? 268435456;
30984
+ const configured = config.requestCacheDir ?? "";
30985
+ pruneRequestImageCache({
30986
+ dir: configured.length > 0 ? configured : resolveRequestImageCacheDir(),
30987
+ ttlMs: ttlDays * 24 * 60 * 60 * 1e3,
30988
+ maxBytes,
30989
+ protectedRoots: [durableAttachmentsRoot(), vaultConfig().dir]
30990
+ }).then((stats) => {
30991
+ if (stats.removed > 0) ctx.logger.info("dsh-image-gen: pruned " + String(stats.removed) + " request-image variants (" + String(stats.freedBytes) + " bytes)");
30992
+ }).catch((error) => {
30993
+ ctx.logger.warn("dsh-image-gen: request-image cache prune failed: " + (error instanceof Error ? error.message : String(error)));
30994
+ });
30995
+ };
30996
+ const startup = setTimeout(run, 3e4);
30997
+ const daily = setInterval(run, 864e5);
30998
+ startup.unref();
30999
+ daily.unref();
31000
+ return () => {
31001
+ clearTimeout(startup);
31002
+ clearInterval(daily);
31003
+ };
31004
+ }, "dsh-image-gen: request image cache upkeep");
29135
31005
  let webAdmission;
29136
31006
  ctx.inject(["connection"], (connectionCtx) => {
29137
31007
  webAdmission = (request) => connectionCtx.connection.requestRejection(request);
@@ -29149,15 +31019,75 @@ function apply(ctx, config = {}) {
29149
31019
  path: IMAGE_ROUTE,
29150
31020
  handler: (req, res) => serveImage(req, res, { readImage: (ref) => ctx.attachments.readImage(ref) })
29151
31021
  }), "dsh-image-gen: image route");
31022
+ webCtx.effect(() => register({
31023
+ kind: "exact",
31024
+ path: THUMB_ROUTE,
31025
+ handler: (req, res) => serveThumb(req, res, { readImageRequest: (ref, target, signal) => ctx.attachments.readImageRequest(ref, target, signal) })
31026
+ }), "dsh-image-gen: thumbnail route");
31027
+ webCtx.effect(() => register({
31028
+ kind: "exact",
31029
+ path: PREVIEW_ROUTE,
31030
+ handler: (req, res) => servePreview(req, res, { readImageRequest: (ref, target, signal) => ctx.attachments.readImageRequest(ref, target, signal) })
31031
+ }), "dsh-image-gen: preview route");
29152
31032
  webCtx.effect(() => register({
29153
31033
  kind: "exact",
29154
31034
  path: IMPORT_ROUTE,
29155
31035
  handler: (req, res) => serveImport(req, res, {
29156
31036
  saveImage: (image) => ctx.attachments.saveImage(image),
29157
31037
  maxImageBytes: ctx.attachments.imageLimits.maxImageBytes,
29158
- mediaTypes: ctx.attachments.imageLimits.mediaTypes
31038
+ mediaTypes: ctx.attachments.imageLimits.mediaTypes,
31039
+ warmThumbnail: (ref) => {
31040
+ ctx.attachments.readImageRequest(ref, {
31041
+ width: 320,
31042
+ height: 320,
31043
+ maxBytes: 98304
31044
+ }).catch(() => void 0);
31045
+ },
31046
+ saveOriginal: (input) => saveImportedOriginal(vaultConfig(), input, typeof req.headers["x-dsh-vault-profile"] === "string" ? req.headers["x-dsh-vault-profile"] : void 0)
29159
31047
  })
29160
31048
  }), "dsh-image-gen: import route");
31049
+ webCtx.effect(() => register({
31050
+ kind: "exact",
31051
+ path: ZIP_ROUTE,
31052
+ handler: (req, res) => serveZip(req, res, {
31053
+ readImage: (ref) => ctx.attachments.readImage(ref),
31054
+ readVault: (key, mediaType) => readOriginal(vaultConfig(), key, mediaType),
31055
+ maxEntries: 2e3
31056
+ })
31057
+ }), "dsh-image-gen: zip export route");
31058
+ webCtx.effect(() => register({
31059
+ kind: "exact",
31060
+ path: ZIP_PROGRESS_ROUTE,
31061
+ handler: (req, res) => serveZipProgress(req, res)
31062
+ }), "dsh-image-gen: zip progress route");
31063
+ webCtx.effect(() => register({
31064
+ kind: "exact",
31065
+ path: ZIP_CANCEL_ROUTE,
31066
+ handler: (req, res) => serveZipCancel(req, res)
31067
+ }), "dsh-image-gen: zip cancel route");
31068
+ webCtx.effect(() => register({
31069
+ kind: "exact",
31070
+ path: VAULT_ROUTE,
31071
+ handler: (req, res) => serveVault(req, res, vaultConfig())
31072
+ }), "dsh-image-gen: vault route");
31073
+ webCtx.effect(() => register({
31074
+ kind: "exact",
31075
+ path: VAULT_REFS_ROUTE,
31076
+ handler: (req, res) => serveVaultRefs(req, res, vaultConfig())
31077
+ }), "dsh-image-gen: vault refs route");
31078
+ webCtx.effect(() => register({
31079
+ kind: "exact",
31080
+ path: VAULT_DELETE_ROUTE,
31081
+ handler: (req, res) => serveVaultDelete(req, res, vaultConfig())
31082
+ }), "dsh-image-gen: vault delete route");
31083
+ webCtx.effect(() => register({
31084
+ kind: "exact",
31085
+ path: STORAGE_STATS_ROUTE,
31086
+ handler: (req, res) => {
31087
+ const configured = current().requestCacheDir?.trim() ?? "";
31088
+ serveStorageStats(req, res, { requestImagesRoot: configured.length > 0 ? configured : resolveRequestImageCacheDir() });
31089
+ }
31090
+ }), "dsh-image-gen: storage stats route");
29161
31091
  webCtx.effect(() => register({
29162
31092
  kind: "exact",
29163
31093
  path: DELETE_ROUTE,
@@ -29885,7 +31815,11 @@ function apply(ctx, config = {}) {
29885
31815
  ...active.provider === "openai-compat" ? {
29886
31816
  editFormat: active.editFormat,
29887
31817
  editExtra: active.editExtra,
29888
- editSizeMode: requestConfig.openaiCompatEditSizeMode ?? "auto"
31818
+ editSizeMode: requestConfig.openaiCompatEditSizeMode ?? "auto",
31819
+ editResponseFormat: "auto",
31820
+ onResponseFormatRetry: (status) => {
31821
+ ctx.logger.warn("dsh-image-gen: openai-compatible relay rejected response_format=b64_json (" + String(status) + "); retrying without it");
31822
+ }
29889
31823
  } : active.provider === "xai" ? {
29890
31824
  editFormat: "xaiJson",
29891
31825
  ...xaiExtraBody === void 0 ? {} : { extraBody: xaiExtraBody }