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.
- package/README.en.md +456 -454
- package/README.md +456 -454
- package/docs/assets/readme/canvas-edit.webp +0 -0
- package/docs/assets/readme/canvas-sketch.webp +0 -0
- package/docs/assets/readme/chat-batch.webp +0 -0
- package/docs/assets/readme/config.webp +0 -0
- package/docs/assets/readme/hero-batch.webp +0 -0
- package/docs/assets/readme/hero-canvas.webp +0 -0
- package/docs/assets/readme/studio-ref.webp +0 -0
- package/docs/assets/readme/studio.webp +0 -0
- package/lib/client.js +4871 -3658
- package/lib/index.js +2074 -140
- package/lib/types/client/browser-image-utils.d.ts +6 -0
- package/lib/types/client/datalist-scroll-fix.d.ts +32 -0
- package/lib/types/client/gallery-batch-bar.d.ts +8 -0
- package/lib/types/client/gallery-card.d.ts +21 -0
- package/lib/types/client/gallery-format.d.ts +7 -0
- package/lib/types/client/gallery-i18n.d.ts +236 -0
- package/lib/types/client/gallery-media.d.ts +46 -0
- package/lib/types/client/gallery-merge.d.ts +15 -0
- package/lib/types/client/gallery-store.d.ts +21 -2
- package/lib/types/client/gallery-thumbs.d.ts +24 -0
- package/lib/types/client/gallery-view.d.ts +0 -207
- package/lib/types/client/image-result-view.d.ts +24 -0
- package/lib/types/client/index.d.ts +1 -20
- package/lib/types/client/plugin-i18n.d.ts +375 -0
- package/lib/types/client/plugin-style.d.ts +1 -0
- package/lib/types/client/studio-helpers.d.ts +63 -0
- package/lib/types/client/studio-style.d.ts +1 -1
- package/lib/types/client/studio-view.d.ts +0 -7
- package/lib/types/client/tl/gallery-image-transfer.d.ts +1 -1
- package/lib/types/config.d.ts +12 -0
- package/lib/types/import-route.d.ts +11 -0
- package/lib/types/multipart.d.ts +29 -0
- package/lib/types/openai-compatible.d.ts +8 -0
- package/lib/types/request-cache.d.ts +41 -0
- package/lib/types/shared.d.ts +23 -0
- package/lib/types/storage-stats.d.ts +34 -0
- package/lib/types/thumb-route.d.ts +25 -0
- package/lib/types/vault-registry.d.ts +36 -0
- package/lib/types/vault-route.d.ts +8 -0
- package/lib/types/vault-store.d.ts +31 -0
- package/lib/types/zip-progress.d.ts +18 -0
- package/lib/types/zip-route.d.ts +25 -0
- package/lib/types/zip-stream.d.ts +43 -0
- package/package.json +139 -139
- package/skills/install-dsh-image-gen/SKILL.md +22 -22
- package/lib/types/client/tl/tl-trace-bridge.d.ts +0 -55
- package/lib/types/client/tl/trace-playback.d.ts +0 -101
- package/lib/types/trace-drawing.d.ts +0 -102
- package/lib/types/trace-route.d.ts +0 -12
- package/lib/types/trace-sketch.d.ts +0 -26
- package/lib/types/trace-store.d.ts +0 -23
- package/lib/types/vendor/drawai/raster.d.ts +0 -43
- 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 {
|
|
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$
|
|
6779
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
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$
|
|
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$
|
|
6816
|
+
body = JSON.parse(await readBody$8(req, deps.maxBodyBytes));
|
|
6786
6817
|
} catch (error) {
|
|
6787
|
-
return jsonError$
|
|
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$
|
|
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$
|
|
6795
|
-
if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$
|
|
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$
|
|
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$
|
|
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
|
|
8150
|
-
if (
|
|
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$
|
|
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$
|
|
8188
|
+
body = JSON.parse(await readBody$7(req));
|
|
8157
8189
|
} catch {
|
|
8158
|
-
return jsonError$
|
|
8190
|
+
return jsonError$10(res, 400, "invalid-request");
|
|
8159
8191
|
}
|
|
8160
8192
|
const attachment = attachmentFromRequest(body);
|
|
8161
|
-
if (attachment === void 0) return jsonError$
|
|
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$
|
|
8204
|
+
jsonError$10(res, 404, "image-unavailable");
|
|
8173
8205
|
}
|
|
8174
8206
|
}
|
|
8175
|
-
|
|
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$
|
|
8184
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
8185
|
-
if (!sameOrigin$
|
|
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$
|
|
8254
|
+
body = JSON.parse(await readBody$7(req));
|
|
8189
8255
|
} catch {
|
|
8190
|
-
return jsonError$
|
|
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$
|
|
8221
|
-
if (req.method !== "POST") return jsonError$
|
|
8222
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
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$
|
|
8290
|
+
const raw = JSON.parse(await readBody$7(req));
|
|
8225
8291
|
const attachment = parseImageAttachmentRef(raw.attachment);
|
|
8226
|
-
if (!attachment) return jsonError$
|
|
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$
|
|
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$
|
|
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
|
|
8283
|
-
|
|
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 >
|
|
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$
|
|
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/
|
|
8305
|
-
|
|
8306
|
-
const
|
|
8307
|
-
|
|
8308
|
-
function
|
|
8309
|
-
return
|
|
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
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
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 =
|
|
10020
|
+
body = await readBody$3(req, 4194304);
|
|
8320
10021
|
} catch {
|
|
8321
|
-
|
|
10022
|
+
jsonError$5(res, 413, "request-too-large");
|
|
10023
|
+
return;
|
|
8322
10024
|
}
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
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
|
-
|
|
8378
|
-
|
|
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,
|
|
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 >
|
|
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
|
|
8539
|
-
const
|
|
8540
|
-
|
|
8541
|
-
const
|
|
8542
|
-
const
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
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)
|
|
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
|
|
8618
|
-
let response
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
})
|
|
8627
|
-
|
|
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)
|
|
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)
|
|
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 }
|