img-upload-kit 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +89 -74
  2. package/MIGRATION.md +192 -0
  3. package/README.md +243 -259
  4. package/dist/bin.js +1186 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/blur.cjs +40 -412
  7. package/dist/blur.cjs.map +1 -1
  8. package/dist/blur.d.cts +5 -5
  9. package/dist/blur.d.ts +5 -5
  10. package/dist/blur.js +39 -393
  11. package/dist/blur.js.map +1 -1
  12. package/dist/blurDataUtils-DveBRtqE.d.cts +43 -0
  13. package/dist/blurDataUtils-yx9Q6HlU.d.ts +43 -0
  14. package/dist/blurService-D5TpMNji.d.ts +83 -0
  15. package/dist/blurService-DacwylJx.d.cts +83 -0
  16. package/dist/chunk-25GYKQ6J.js +158 -0
  17. package/dist/chunk-25GYKQ6J.js.map +1 -0
  18. package/dist/chunk-34L4TEE3.cjs +226 -0
  19. package/dist/chunk-34L4TEE3.cjs.map +1 -0
  20. package/dist/chunk-DFCSN3X7.cjs +158 -0
  21. package/dist/chunk-DFCSN3X7.cjs.map +1 -0
  22. package/dist/chunk-EWVIL57J.js +294 -0
  23. package/dist/chunk-EWVIL57J.js.map +1 -0
  24. package/dist/chunk-FP5YIWG5.cjs +135 -0
  25. package/dist/chunk-FP5YIWG5.cjs.map +1 -0
  26. package/dist/chunk-HILELQGS.js +226 -0
  27. package/dist/chunk-HILELQGS.js.map +1 -0
  28. package/dist/chunk-IDLLCVGB.js +2370 -0
  29. package/dist/chunk-IDLLCVGB.js.map +1 -0
  30. package/dist/chunk-J6A44Y3W.js +514 -0
  31. package/dist/chunk-J6A44Y3W.js.map +1 -0
  32. package/dist/chunk-LOSVFO56.cjs +145 -0
  33. package/dist/chunk-LOSVFO56.cjs.map +1 -0
  34. package/dist/chunk-MBQJ6NVJ.cjs +294 -0
  35. package/dist/chunk-MBQJ6NVJ.cjs.map +1 -0
  36. package/dist/chunk-MHGQEBPW.js +145 -0
  37. package/dist/chunk-MHGQEBPW.js.map +1 -0
  38. package/dist/chunk-QHU7FUO4.cjs +37 -0
  39. package/dist/chunk-QHU7FUO4.cjs.map +1 -0
  40. package/dist/chunk-S2IFAWJK.js +37 -0
  41. package/dist/chunk-S2IFAWJK.js.map +1 -0
  42. package/dist/chunk-TA5Y64JM.cjs +2370 -0
  43. package/dist/chunk-TA5Y64JM.cjs.map +1 -0
  44. package/dist/chunk-VYVZVUNI.js +135 -0
  45. package/dist/chunk-VYVZVUNI.js.map +1 -0
  46. package/dist/chunk-XKQMCY5Z.cjs +514 -0
  47. package/dist/chunk-XKQMCY5Z.cjs.map +1 -0
  48. package/dist/cli.cjs +273 -76
  49. package/dist/cli.cjs.map +1 -1
  50. package/dist/cli.js +269 -73
  51. package/dist/cli.js.map +1 -1
  52. package/dist/headless.cjs +8 -1880
  53. package/dist/headless.cjs.map +1 -1
  54. package/dist/headless.d.cts +3 -1
  55. package/dist/headless.d.ts +3 -1
  56. package/dist/headless.js +25 -1886
  57. package/dist/headless.js.map +1 -1
  58. package/dist/index-BIdnqtW8.d.ts +234 -0
  59. package/dist/index-Brk_uHs6.d.cts +234 -0
  60. package/dist/{index-FVXXKf2v.d.ts → index-CPMlaQsC.d.ts} +76 -153
  61. package/dist/{index-FVXXKf2v.d.cts → index-DHINe-rA.d.cts} +76 -153
  62. package/dist/index.cjs +64 -532
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +7 -4
  65. package/dist/index.d.ts +7 -4
  66. package/dist/index.js +63 -504
  67. package/dist/index.js.map +1 -1
  68. package/dist/{memoryAdapter-CBu7_3Pp.d.ts → memoryAdapter-Ck0ccZbF.d.cts} +3 -2
  69. package/dist/{memoryAdapter-BJjyLR-1.d.cts → memoryAdapter-DxEKaG0b.d.ts} +3 -2
  70. package/dist/placeholders-C4ftwaII.d.ts +5 -0
  71. package/dist/placeholders-x_3rt65M.d.cts +5 -0
  72. package/dist/react.cjs +1952 -3257
  73. package/dist/react.cjs.map +1 -1
  74. package/dist/react.d.cts +188 -50
  75. package/dist/react.d.ts +188 -50
  76. package/dist/react.js +1763 -3013
  77. package/dist/react.js.map +1 -1
  78. package/dist/server-handler.cjs +14 -0
  79. package/dist/server-handler.cjs.map +1 -0
  80. package/dist/server-handler.d.cts +3 -0
  81. package/dist/server-handler.d.ts +3 -0
  82. package/dist/server-handler.js +14 -0
  83. package/dist/server-handler.js.map +1 -0
  84. package/dist/server.cjs +1096 -532
  85. package/dist/server.cjs.map +1 -1
  86. package/dist/server.d.cts +77 -154
  87. package/dist/server.d.ts +77 -154
  88. package/dist/server.js +1050 -448
  89. package/dist/server.js.map +1 -1
  90. package/dist/storage.cjs +239 -232
  91. package/dist/storage.cjs.map +1 -1
  92. package/dist/storage.d.cts +21 -5
  93. package/dist/storage.d.ts +21 -5
  94. package/dist/storage.js +227 -207
  95. package/dist/storage.js.map +1 -1
  96. package/dist/styles/animated-image.css +112 -0
  97. package/dist/styles/dropzone-and-tiles.css +247 -0
  98. package/dist/styles/modals-and-dock.css +248 -0
  99. package/dist/styles/primitives.css +197 -0
  100. package/dist/styles/quality-and-inputs.css +184 -0
  101. package/dist/styles/tokens.css +123 -0
  102. package/dist/styles.css +235 -220
  103. package/dist/types-BrP-o9nd.d.cts +85 -0
  104. package/dist/types-sZ3KoFQY.d.ts +85 -0
  105. package/dist/types-yShGNQnW.d.cts +61 -0
  106. package/dist/types-yShGNQnW.d.ts +61 -0
  107. package/dist/{types-CiLY0DwB.d.cts → upload-C5Trd6Dh.d.cts} +27 -53
  108. package/dist/{types-CiLY0DwB.d.ts → upload-C5Trd6Dh.d.ts} +27 -53
  109. package/package.json +92 -32
  110. package/dist/blurService-DqsnfPTd.d.cts +0 -172
  111. package/dist/blurService-DqsnfPTd.d.ts +0 -172
package/dist/server.js CHANGED
@@ -1,323 +1,274 @@
1
- import sharp from 'sharp';
2
- import { encode } from 'blurhash';
3
- import lqip from 'lqip-modern';
4
- import crypto from 'crypto';
5
- import fs from 'fs';
6
- import path from 'path';
1
+ import {
2
+ UploadValidationError,
3
+ createUploadHandler,
4
+ createUploadHttpHandler,
5
+ isClientValidationError
6
+ } from "./chunk-J6A44Y3W.js";
7
+ import "./chunk-S2IFAWJK.js";
8
+ import {
9
+ DEFAULT_ALLOWED_FORMATS,
10
+ DEFAULT_IMAGE_DIMENSIONS,
11
+ STANDARD_PRESETS,
12
+ SVG_MIME_TYPE,
13
+ hexToRgb,
14
+ rgbToHex,
15
+ validateImageFileStrict
16
+ } from "./chunk-HILELQGS.js";
7
17
 
8
- // src/server/handler.ts
9
- var UploadValidationError = class extends Error {
10
- status = 400;
11
- constructor(message) {
12
- super(message);
13
- this.name = "UploadValidationError";
14
- }
15
- };
16
- var VALIDATION_ERROR_SUBSTRINGS = [
17
- "exceeds maximum",
18
- "file size",
19
- "too large",
20
- "is not allowed",
21
- "not allowed",
22
- "unsupported image format",
23
- "unsupported format",
24
- "invalid format",
25
- "invalid image",
26
- "not a valid image",
27
- "file type",
28
- "invalid preset",
29
- "no file provided",
30
- "file is empty"
18
+ // src/server/optimization/types.ts
19
+ var DEFAULT_LIMIT_INPUT_PIXELS = 5e7;
20
+ var PRIMARY_AVIF_EFFORT = 6;
21
+ var RETRY_AVIF_EFFORT = 5;
22
+ var BLUR_PLACEHOLDER_WIDTH = 128;
23
+ var BLUR_PLACEHOLDER_FALLBACK_WIDTH = 96;
24
+ var BLUR_PLACEHOLDER_QUALITY = 80;
25
+ var BLUR_PLACEHOLDER_MAX_CHARS = 8e3;
26
+ var EDGE_COLOR_FALLBACK = "#6B7280";
27
+ var EDGE_WHITE_FALLBACK = "#F9FAFB";
28
+ var WHITE_DOMINANCE_RATIO = 1.35;
29
+
30
+ // src/server/security/sanitizeSvg.ts
31
+ var DEFAULT_MAX_SVG_BYTES = 1024 * 1024;
32
+ var FORBIDDEN_ELEMENTS = [
33
+ "script",
34
+ "foreignObject",
35
+ "iframe",
36
+ "embed",
37
+ "object",
38
+ "applet",
39
+ "meta",
40
+ "link",
41
+ "base",
42
+ "audio",
43
+ "video",
44
+ "canvas",
45
+ "animate",
46
+ "animateMotion",
47
+ "animateTransform",
48
+ "set",
49
+ "handler",
50
+ "listener",
51
+ "tbreak"
31
52
  ];
32
- function isClientValidationError(error) {
33
- if (!error) return false;
34
- if (error instanceof UploadValidationError) return true;
35
- if (typeof error === "object" && error !== null && "status" in error && error.status === 400) {
36
- return true;
53
+ var SAFE_NAMED_ENTITIES = /* @__PURE__ */ new Set(["amp", "lt", "gt", "quot", "apos"]);
54
+ var SAFE_HREF_FRAGMENT_RE = /^#[a-zA-Z0-9_.:-]+$/;
55
+ var SAFE_DATA_IMAGE_RE = /^data:image\/(?:png|jpeg|webp);base64,[a-zA-Z0-9+/=\s]+$/i;
56
+ function stripControlAndSpace(str) {
57
+ let out = "";
58
+ for (let i = 0; i < str.length; i++) {
59
+ if (str.charCodeAt(i) > 32) out += str[i];
37
60
  }
38
- if (error instanceof Error && (error.name === "UploadValidationError" || error.name === "ValidationError")) {
61
+ return out;
62
+ }
63
+ function decodeHtmlEntitiesForCheck(value) {
64
+ const decoded = value.replace(
65
+ /&#x([0-9a-fA-F]+);?/g,
66
+ (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16) || 0)
67
+ ).replace(
68
+ /&#(\d+);?/g,
69
+ (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10) || 0)
70
+ );
71
+ return stripControlAndSpace(decoded);
72
+ }
73
+ function isSafeSvgHref(rawValue) {
74
+ const trimmed = rawValue.trim();
75
+ if (SAFE_HREF_FRAGMENT_RE.test(trimmed)) return true;
76
+ const decoded = decodeHtmlEntitiesForCheck(trimmed);
77
+ if (SAFE_HREF_FRAGMENT_RE.test(decoded)) return true;
78
+ if (SAFE_DATA_IMAGE_RE.test(trimmed) && SAFE_DATA_IMAGE_RE.test(decoded)) {
39
79
  return true;
40
80
  }
41
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
42
- return VALIDATION_ERROR_SUBSTRINGS.some((sub) => msg.includes(sub));
81
+ return false;
43
82
  }
44
- function jsonResponse(body, status = 200) {
45
- return new Response(JSON.stringify(body), {
46
- status,
47
- headers: { "Content-Type": "application/json" }
83
+ function sanitizeCssBlock(css) {
84
+ return css.replace(/@import\b[^;{}]*(?:;|$)/gi, "").replace(/expression\s*\([^)]*\)/gi, "").replace(/(?:javascript|vbscript|data)\s*:[^;)"']*/gi, "").replace(/-moz-binding\s*:[^;}"']*/gi, "").replace(/behavior\s*:[^;}"']*/gi, "").replace(/url\s*\(\s*(['"]?)(.*?)\1\s*\)/gi, (full, _q, target) => {
85
+ const cleaned = decodeHtmlEntitiesForCheck(target.trim());
86
+ return SAFE_HREF_FRAGMENT_RE.test(cleaned) ? full : "none";
48
87
  });
49
88
  }
50
- function parseJsonRecord(raw) {
51
- if (!raw) return void 0;
52
- try {
53
- const parsed = JSON.parse(raw);
54
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
55
- const clean = {};
56
- for (const [k, v] of Object.entries(parsed)) {
57
- if (v !== void 0 && v !== null) {
58
- clean[k] = String(v);
59
- }
60
- }
61
- return Object.keys(clean).length > 0 ? clean : void 0;
89
+ function sanitizeTagAttributes(attrs) {
90
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
91
+ const kept = [];
92
+ for (const match of attrs.matchAll(attrRe)) {
93
+ const rawName = match[1];
94
+ const lowerName = rawName.toLowerCase();
95
+ const localName = lowerName.includes(":") ? lowerName.split(":")[1] : lowerName;
96
+ const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
97
+ if (lowerName.startsWith("on") || localName.startsWith("on")) {
98
+ continue;
62
99
  }
63
- } catch {
64
- }
65
- return void 0;
66
- }
67
- function createUploadHttpHandler(options) {
68
- const {
69
- uploader,
70
- authorize,
71
- allowedPresets,
72
- allowClientBucketOverride = false
73
- } = options;
74
- async function POST(request) {
75
- try {
76
- if (authorize) {
77
- const allowed = await authorize(request);
78
- if (!allowed) {
79
- return jsonResponse({ error: "Unauthorized" }, 401);
80
- }
81
- }
82
- const formData = await request.formData();
83
- const file = formData.get("file");
84
- if (!file || typeof file === "string" || typeof file.size === "number" && file.size === 0 && !file.name) {
85
- return jsonResponse({ error: "No file provided" }, 400);
86
- }
87
- if (typeof file.size === "number" && file.size === 0) {
88
- return jsonResponse({ error: "File is empty" }, 400);
89
- }
90
- const preset = formData.get("preset") || void 0;
91
- if (preset) {
92
- if (allowedPresets && allowedPresets.length > 0 && !allowedPresets.includes(preset)) {
93
- return jsonResponse(
94
- {
95
- error: `Invalid preset "${preset}". Allowed presets: ${allowedPresets.join(", ")}`
96
- },
97
- 400
98
- );
99
- }
100
- if (typeof uploader.resolvePreset === "function" && !uploader.resolvePreset(preset)) {
101
- return jsonResponse(
102
- {
103
- error: `Invalid preset "${preset}".`
104
- },
105
- 400
106
- );
107
- }
108
- }
109
- const folder = formData.get("folder") || void 0;
110
- const bucket = allowClientBucketOverride ? formData.get("bucket") || void 0 : void 0;
111
- const altText = formData.get("altText") || void 0;
112
- const customFilename = formData.get("customFilename") || void 0;
113
- const outputFormatRaw = formData.get("outputFormat");
114
- if (outputFormatRaw && !["jpeg", "png", "webp", "avif"].includes(outputFormatRaw)) {
115
- return jsonResponse(
116
- {
117
- error: `Invalid output format "${outputFormatRaw}". Allowed formats: jpeg, png, webp, avif`
118
- },
119
- 400
120
- );
100
+ if (localName === "href" || lowerName === "xlink:href" || localName === "src" || localName === "action" || localName === "formaction" || localName === "data") {
101
+ if (!isSafeSvgHref(rawValue)) {
102
+ continue;
121
103
  }
122
- const outputFormat = outputFormatRaw || void 0;
123
- const qualityRaw = formData.get("quality");
124
- const quality = qualityRaw !== null ? Math.max(1, Math.min(100, parseInt(qualityRaw, 10) || 85)) : void 0;
125
- const maxWidthRaw = formData.get("maxWidth");
126
- const maxHeightRaw = formData.get("maxHeight");
127
- let maxDimensions;
128
- if (maxWidthRaw && maxHeightRaw) {
129
- const w = parseInt(maxWidthRaw, 10);
130
- const h = parseInt(maxHeightRaw, 10);
131
- if (w > 0 && h > 0) {
132
- maxDimensions = { width: w, height: h };
133
- }
104
+ const escaped = rawValue.replace(/"/g, "&quot;");
105
+ kept.push(`${rawName}="${escaped}"`);
106
+ continue;
107
+ }
108
+ if (localName === "style") {
109
+ const cleanStyle = sanitizeCssBlock(rawValue).replace(/"/g, "'");
110
+ if (cleanStyle.trim()) {
111
+ kept.push(`style="${cleanStyle}"`);
134
112
  }
135
- const metadata = parseJsonRecord(
136
- formData.get("metadata")
137
- );
138
- const result = await uploader.uploadImage(file, {
139
- preset,
140
- folder,
141
- bucket,
142
- altText,
143
- customFilename,
144
- outputFormat,
145
- quality,
146
- maxDimensions,
147
- metadata
148
- });
149
- return jsonResponse(result, 200);
150
- } catch (error) {
151
- const errorMessage = error instanceof Error ? error.message : "Internal server error";
152
- const status = isClientValidationError(error) ? 400 : 500;
153
- return jsonResponse({ error: errorMessage }, status);
113
+ continue;
114
+ }
115
+ const decodedVal = decodeHtmlEntitiesForCheck(rawValue).toLowerCase();
116
+ if (decodedVal.includes("javascript:") || decodedVal.includes("vbscript:") || decodedVal.includes("data:text/") || decodedVal.includes("data:image/svg")) {
117
+ continue;
118
+ }
119
+ if (/url\s*\(/i.test(rawValue)) {
120
+ const sanitizedUrlAttr = sanitizeCssBlock(rawValue).replace(/"/g, "&quot;");
121
+ kept.push(`${rawName}="${sanitizedUrlAttr}"`);
122
+ continue;
123
+ }
124
+ if (match[2] !== void 0 || match[3] !== void 0 || match[4] !== void 0) {
125
+ kept.push(`${rawName}="${rawValue.replace(/"/g, "&quot;")}"`);
126
+ } else {
127
+ kept.push(rawName);
154
128
  }
155
129
  }
156
- async function DELETE(request) {
157
- try {
158
- if (authorize) {
159
- const allowed = await authorize(request);
160
- if (!allowed) {
161
- return jsonResponse({ error: "Unauthorized" }, 401);
162
- }
163
- }
164
- const { searchParams } = new URL(request.url);
165
- const url = searchParams.get("url");
166
- if (url) {
167
- const deleted = await uploader.deleteByUrl(url);
168
- return jsonResponse({ success: deleted }, deleted ? 200 : 400);
169
- }
170
- const storagePath = searchParams.get("path");
171
- const bucket = allowClientBucketOverride ? searchParams.get("bucket") || void 0 : void 0;
172
- if (!storagePath) {
173
- return jsonResponse(
174
- { error: 'Missing required "path" or "url" query parameter' },
175
- 400
176
- );
177
- }
178
- await uploader.deleteImage(storagePath, bucket);
179
- return jsonResponse({ success: true }, 200);
180
- } catch (error) {
181
- const errorMessage = error instanceof Error ? error.message : "Internal server error";
182
- return jsonResponse({ error: errorMessage }, 500);
130
+ return kept.length > 0 ? ` ${kept.join(" ")}` : "";
131
+ }
132
+ function sanitizeSvg(input, options = {}) {
133
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_SVG_BYTES;
134
+ const byteLength = typeof input === "string" ? Buffer.byteLength(input, "utf8") : input.length;
135
+ if (byteLength > maxBytes) {
136
+ throw new UploadValidationError(
137
+ `SVG file size (${byteLength} bytes) exceeds maximum allowed SVG size of ${maxBytes} bytes`
138
+ );
139
+ }
140
+ let svg = typeof input === "string" ? input : input.toString("utf8");
141
+ svg = svg.replace(/^\uFEFF/, "");
142
+ svg = svg.replace(/<!DOCTYPE[^[>]*(?:\[[\s\S]*?\]\s*)?>/gi, "");
143
+ svg = svg.replace(/<!ENTITY[\s\S]*?>/gi, "");
144
+ svg = svg.replace(/<!--[\s\S]*?-->/g, "");
145
+ svg = svg.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
146
+ svg = svg.replace(/<\?(?!xml\b)[\s\S]*?\?>/gi, "");
147
+ svg = svg.replace(
148
+ /&([a-zA-Z_][\w.-]*);/g,
149
+ (full, name) => SAFE_NAMED_ENTITIES.has(name) ? full : ""
150
+ );
151
+ const tagPattern = FORBIDDEN_ELEMENTS.join("|");
152
+ const pairedRe = new RegExp(
153
+ `<\\s*(?:[a-zA-Z0-9_-]+:)?(?:${tagPattern})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*(?:[a-zA-Z0-9_-]+:)?(?:${tagPattern})\\s*>`,
154
+ "gi"
155
+ );
156
+ const singleRe = new RegExp(
157
+ `<\\s*\\/?\\s*(?:[a-zA-Z0-9_-]+:)?(?:${tagPattern})\\b[^>]*\\/?>`,
158
+ "gi"
159
+ );
160
+ const htmlNamespaceRe = /<\s*\/?\s*(?:xhtml|html):[a-zA-Z0-9_-]+\b[^>]*\/?>/gi;
161
+ for (let pass = 0; pass < 4; pass++) {
162
+ const prev = svg;
163
+ svg = svg.replace(pairedRe, "").replace(singleRe, "").replace(htmlNamespaceRe, "");
164
+ if (svg === prev) break;
165
+ }
166
+ svg = svg.replace(
167
+ /(<\s*style\b[^>]*>)([\s\S]*?)(<\s*\/\s*style\s*>)/gi,
168
+ (_m, open, body, close) => `${open}${sanitizeCssBlock(body)}${close}`
169
+ );
170
+ svg = svg.replace(
171
+ /<([a-zA-Z_:][\w:.-]*)(\s[^<>]*?)?(\s*\/?)>/g,
172
+ (_full, tagName, attrs, selfClose) => {
173
+ const cleanAttrs = attrs ? sanitizeTagAttributes(attrs) : "";
174
+ return `<${tagName}${cleanAttrs}${selfClose}>`;
183
175
  }
176
+ );
177
+ if (!/<svg[\s>/]/i.test(svg)) {
178
+ throw new UploadValidationError(
179
+ "Invalid SVG: missing root <svg> element after sanitization"
180
+ );
181
+ }
182
+ if (pairedRe.test(svg) || singleRe.test(svg) || /<!(?:DOCTYPE|ENTITY)\b/i.test(svg) || /\bon[a-z]+\s*=/i.test(svg) || /(?:javascript|vbscript)\s*:/i.test(svg)) {
183
+ throw new UploadValidationError(
184
+ "Invalid SVG: unsafe content remained after sanitization"
185
+ );
184
186
  }
185
- return { POST, DELETE };
187
+ return Buffer.from(svg.trim(), "utf8");
186
188
  }
187
189
 
188
- // src/server/optimization/types.ts
189
- var PRIMARY_AVIF_EFFORT = 6;
190
- var RETRY_AVIF_EFFORT = 5;
191
- var BLUR_PLACEHOLDER_WIDTH = 128;
192
- var BLUR_PLACEHOLDER_FALLBACK_WIDTH = 96;
193
- var BLUR_PLACEHOLDER_QUALITY = 80;
194
- var BLUR_PLACEHOLDER_MAX_CHARS = 8e3;
195
- var EDGE_COLOR_FALLBACK = "#6B7280";
196
- var EDGE_WHITE_FALLBACK = "#F9FAFB";
197
- var WHITE_DOMINANCE_RATIO = 1.35;
190
+ // src/server/optimization/loadSharp.ts
191
+ var SHARP_MISSING_MESSAGE = "img-upload-kit/server requires `sharp` \u2014 run `npm i sharp`";
192
+ var sharpPromise = null;
193
+ function unwrapDefault(mod) {
194
+ const record = mod;
195
+ return record && record.default !== void 0 ? record.default : mod;
196
+ }
197
+ function loadSharp() {
198
+ if (!sharpPromise) {
199
+ sharpPromise = import("sharp").then(
200
+ (mod) => unwrapDefault(mod),
201
+ (error) => {
202
+ sharpPromise = null;
203
+ throw new Error(SHARP_MISSING_MESSAGE, { cause: error });
204
+ }
205
+ );
206
+ }
207
+ return sharpPromise;
208
+ }
198
209
 
199
- // src/types/presets.ts
200
- var DEFAULT_ALLOWED_FORMATS = [
201
- "image/jpeg",
202
- "image/png",
203
- "image/webp",
204
- "image/avif",
205
- "image/svg+xml"
206
- ];
207
- var RASTER_ALLOWED_FORMATS = [
208
- "image/jpeg",
209
- "image/png",
210
- "image/webp",
211
- "image/avif"
212
- ];
213
- var DEFAULT_IMAGE_DIMENSIONS = {
214
- width: 1920,
215
- height: 1080
216
- };
217
- var STANDARD_PRESETS = {
218
- thumbnail: {
219
- folder: "thumbnails",
220
- maxDimensions: { width: 600, height: 600 },
221
- maxSizeMB: 5,
222
- quality: 78,
223
- recommendedQualityLevel: "medium",
224
- allowedFormats: RASTER_ALLOWED_FORMATS,
225
- recommendationNote: {
226
- en: "Thumbnails display at compact sizes \u2014 Medium quality keeps pages fast",
227
- ar: "\u0627\u0644\u0645\u0635\u063A\u0631\u0627\u062A \u062A\u0639\u0631\u0636 \u0628\u0623\u062D\u062C\u0627\u0645 \u0635\u063A\u064A\u0631\u0629 \u2014 \u0627\u0644\u062C\u0648\u062F\u0629 \u0627\u0644\u0645\u062A\u0648\u0633\u0637\u0629 \u062A\u062D\u0627\u0641\u0638 \u0639\u0644\u0649 \u0633\u0631\u0639\u0629 \u0627\u0644\u0635\u0641\u062D\u0629"
228
- }
229
- },
230
- avatar: {
231
- folder: "avatars",
232
- maxDimensions: { width: 512, height: 512 },
233
- maxSizeMB: 5,
234
- quality: 80,
235
- recommendedQualityLevel: "medium",
236
- allowedFormats: RASTER_ALLOWED_FORMATS,
237
- recommendationNote: {
238
- en: "Avatars display small \u2014 Medium quality balances sharpness and speed",
239
- ar: "\u0627\u0644\u0635\u0648\u0631 \u0627\u0644\u0634\u062E\u0635\u064A\u0629 \u0635\u063A\u064A\u0631\u0629 \u2014 \u0627\u0644\u062C\u0648\u062F\u0629 \u0627\u0644\u0645\u062A\u0648\u0633\u0637\u0629 \u062A\u0648\u0627\u0632\u0646 \u0628\u064A\u0646 \u0627\u0644\u0648\u0636\u0648\u062D \u0648\u0627\u0644\u0633\u0631\u0639\u0629"
240
- }
241
- },
242
- card: {
243
- folder: "cards",
244
- maxDimensions: { width: 1200, height: 900 },
245
- maxSizeMB: 8,
246
- quality: 85,
247
- recommendedQualityLevel: "high",
248
- allowedFormats: DEFAULT_ALLOWED_FORMATS
249
- },
250
- cover: {
251
- folder: "covers",
252
- maxDimensions: { width: 1920, height: 1080 },
253
- maxSizeMB: 10,
254
- quality: 88,
255
- recommendedQualityLevel: "high",
256
- allowedFormats: DEFAULT_ALLOWED_FORMATS
257
- },
258
- hero: {
259
- folder: "hero",
260
- maxDimensions: { width: 2560, height: 1440 },
261
- maxSizeMB: 15,
262
- quality: 92,
263
- recommendedQualityLevel: "maximum",
264
- allowedFormats: DEFAULT_ALLOWED_FORMATS,
265
- recommendationNote: {
266
- en: "Full-width hero visuals benefit from Maximum quality to preserve fine detail",
267
- ar: "\u0627\u0644\u0635\u0648\u0631 \u0627\u0644\u0631\u0626\u064A\u0633\u064A\u0629 \u0628\u0639\u0631\u0636 \u0643\u0627\u0645\u0644 \u062A\u0633\u062A\u0641\u064A\u062F \u0645\u0646 \u0627\u0644\u062C\u0648\u062F\u0629 \u0627\u0644\u0642\u0635\u0648\u0649 \u0644\u0644\u062D\u0641\u0627\u0638 \u0639\u0644\u0649 \u0627\u0644\u062A\u0641\u0627\u0635\u064A\u0644 \u0627\u0644\u062F\u0642\u064A\u0642\u0629"
210
+ // src/server/optimization/imageOptimizer.ts
211
+ var PASSTHROUGH_FORMATS = /* @__PURE__ */ new Set(["jpeg", "png", "webp", "avif"]);
212
+ function extractSvgDimensions(svgText, metaWidth, metaHeight) {
213
+ let width = metaWidth;
214
+ let height = metaHeight;
215
+ if (!width || !height) {
216
+ const svgTagMatch = svgText.match(/<svg\b[^>]*>/i);
217
+ if (svgTagMatch) {
218
+ const tag = svgTagMatch[0];
219
+ const wMatch = tag.match(/\bwidth\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i);
220
+ const hMatch = tag.match(/\bheight\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i);
221
+ if (!width && wMatch) width = Math.round(parseFloat(wMatch[1]));
222
+ if (!height && hMatch) height = Math.round(parseFloat(hMatch[1]));
268
223
  }
269
- },
270
- logo: {
271
- folder: "logos",
272
- maxDimensions: { width: 800, height: 800 },
273
- maxSizeMB: 5,
274
- quality: 90,
275
- recommendedQualityLevel: "high",
276
- allowedFormats: DEFAULT_ALLOWED_FORMATS,
277
- recommendationNote: {
278
- en: "Prefer SVG for vector logos \u2014 High quality preserves crisp edges on raster logos",
279
- ar: "\u064A\u0641\u0636\u0644 SVG \u0644\u0644\u0634\u0639\u0627\u0631\u0627\u062A \u0627\u0644\u0645\u062A\u062C\u0647\u0629 \u2014 \u0627\u0644\u062C\u0648\u062F\u0629 \u0627\u0644\u0639\u0627\u0644\u064A\u0629 \u062A\u062D\u0627\u0641\u0638 \u0639\u0644\u0649 \u062D\u0648\u0627\u0641 \u0648\u0627\u0636\u062D\u0629"
224
+ }
225
+ if (!width || !height) {
226
+ const viewBoxMatch = svgText.match(
227
+ /viewBox\s*=\s*["']\s*([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)\s*["']/i
228
+ );
229
+ if (viewBoxMatch) {
230
+ if (!width) width = Math.round(parseFloat(viewBoxMatch[3]));
231
+ if (!height) height = Math.round(parseFloat(viewBoxMatch[4]));
280
232
  }
281
- },
282
- original: {
283
- folder: "originals",
284
- maxDimensions: { width: 4096, height: 4096 },
285
- maxSizeMB: 25,
286
- quality: 95,
287
- recommendedQualityLevel: "maximum",
288
- allowedFormats: DEFAULT_ALLOWED_FORMATS
289
233
  }
290
- };
291
-
292
- // src/server/optimization/imageOptimizer.ts
234
+ return {
235
+ width: width && width > 0 ? width : 600,
236
+ height: height && height > 0 ? height : 600
237
+ };
238
+ }
293
239
  async function optimizeImage(buffer, options = {}) {
240
+ const sharp = await loadSharp();
294
241
  const maxDimensions = options.maxDimensions || DEFAULT_IMAGE_DIMENSIONS;
295
242
  const { outputFormat, quality } = options;
243
+ const limitInputPixels = options.limitInputPixels ?? DEFAULT_LIMIT_INPUT_PIXELS;
244
+ const primaryEffort = options.effort ?? PRIMARY_AVIF_EFFORT;
245
+ const retryEffort = options.effort !== void 0 ? Math.max(1, options.effort - 1) : RETRY_AVIF_EFFORT;
296
246
  let pipeline = sharp(buffer, {
297
- failOnError: false,
298
- limitInputPixels: 268402689
247
+ failOn: "error",
248
+ limitInputPixels
299
249
  }).rotate();
300
- const metadata = await pipeline.metadata();
250
+ let metadata;
251
+ try {
252
+ metadata = await pipeline.metadata();
253
+ } catch (err) {
254
+ const msg = err instanceof Error ? err.message : String(err);
255
+ if (msg.toLowerCase().includes("pixel limit") || msg.toLowerCase().includes("exceeds") || msg.toLowerCase().includes("unsupported image format")) {
256
+ throw new UploadValidationError(msg);
257
+ }
258
+ throw err;
259
+ }
301
260
  const originalFormat = metadata.format;
302
261
  if (originalFormat === "svg") {
303
- let width = metadata.width;
304
- let height = metadata.height;
305
- if (!width || !height) {
306
- const svgText = buffer.toString("utf8");
307
- const viewBoxMatch = svgText.match(
308
- /viewBox\s*=\s*["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i
309
- );
310
- if (viewBoxMatch) {
311
- width = Math.round(parseFloat(viewBoxMatch[3]));
312
- height = Math.round(parseFloat(viewBoxMatch[4]));
313
- }
314
- }
262
+ const sanitizedBuffer = sanitizeSvg(buffer);
263
+ const svgText = sanitizedBuffer.toString("utf8");
264
+ const dims = extractSvgDimensions(svgText, metadata.width, metadata.height);
315
265
  return {
316
- buffer,
317
- width: width || maxDimensions.width || 600,
318
- height: height || maxDimensions.height || 600,
266
+ buffer: sanitizedBuffer,
267
+ width: dims.width,
268
+ height: dims.height,
319
269
  format: "svg",
320
- size: buffer.length
270
+ size: sanitizedBuffer.length,
271
+ ...quality !== void 0 ? { quality } : {}
321
272
  };
322
273
  }
323
274
  const isPhoto = originalFormat === "jpeg" || Boolean(metadata.density && metadata.density > 150);
@@ -343,25 +294,26 @@ async function optimizeImage(buffer, options = {}) {
343
294
  width: outputBuffer.info.width || maxDimensions.width,
344
295
  height: outputBuffer.info.height || maxDimensions.height,
345
296
  format: outputFormat,
346
- size: outputBuffer.info.size
297
+ size: outputBuffer.info.size,
298
+ quality: baseQuality
347
299
  };
348
300
  }
349
301
  const avifBuffer = await pipeline.clone().avif({
350
302
  quality: baseQuality,
351
- effort: PRIMARY_AVIF_EFFORT,
303
+ effort: primaryEffort,
352
304
  chromaSubsampling: "4:2:0",
353
305
  bitdepth: 8
354
306
  }).toBuffer({ resolveWithObject: true });
355
307
  const avifSize = avifBuffer.info.size;
356
308
  const originalSize = buffer.length;
357
309
  if (avifSize <= originalSize || outputFormat === "avif") {
358
- const finalMetadata = await sharp(avifBuffer.data).metadata();
359
310
  return {
360
311
  buffer: avifBuffer.data,
361
- width: finalMetadata.width ?? maxDimensions.width,
362
- height: finalMetadata.height ?? maxDimensions.height,
312
+ width: avifBuffer.info.width || maxDimensions.width,
313
+ height: avifBuffer.info.height || maxDimensions.height,
363
314
  format: "avif",
364
- size: avifSize
315
+ size: avifSize,
316
+ quality: baseQuality
365
317
  };
366
318
  }
367
319
  const scaledQuality = Math.max(
@@ -370,66 +322,85 @@ async function optimizeImage(buffer, options = {}) {
370
322
  );
371
323
  const avifRetry = await pipeline.clone().avif({
372
324
  quality: scaledQuality,
373
- effort: RETRY_AVIF_EFFORT,
325
+ effort: retryEffort,
374
326
  chromaSubsampling: "4:2:0",
375
327
  bitdepth: 8
376
328
  }).toBuffer({ resolveWithObject: true });
377
329
  if (avifRetry.info.size <= originalSize) {
378
- const retryMeta = await sharp(avifRetry.data).metadata();
379
330
  return {
380
331
  buffer: avifRetry.data,
381
- width: retryMeta.width ?? maxDimensions.width,
382
- height: retryMeta.height ?? maxDimensions.height,
332
+ width: avifRetry.info.width || maxDimensions.width,
333
+ height: avifRetry.info.height || maxDimensions.height,
383
334
  format: "avif",
384
- size: avifRetry.info.size
335
+ size: avifRetry.info.size,
336
+ quality: scaledQuality
385
337
  };
386
338
  }
387
- const wasDimensionCapped = (metadata.width ?? 0) > maxDimensions.width || (metadata.height ?? 0) > maxDimensions.height;
388
- if (!wasDimensionCapped) {
389
- const PASSTHROUGH_FORMATS = /* @__PURE__ */ new Set(["jpeg", "png", "webp", "avif"]);
390
- const srcFmt = PASSTHROUGH_FORMATS.has(originalFormat ?? "") ? originalFormat : "jpeg";
339
+ const isRotated90 = typeof metadata.orientation === "number" && metadata.orientation >= 5 && metadata.orientation <= 8;
340
+ const orientedWidth = isRotated90 ? metadata.height : metadata.width;
341
+ const orientedHeight = isRotated90 ? metadata.width : metadata.height;
342
+ const wasDimensionCapped = (orientedWidth ?? 0) > maxDimensions.width || (orientedHeight ?? 0) > maxDimensions.height;
343
+ if (!wasDimensionCapped && PASSTHROUGH_FORMATS.has(originalFormat ?? "")) {
344
+ const srcFmt = originalFormat;
345
+ let passthrough = pipeline.clone();
346
+ if (srcFmt === "jpeg") {
347
+ passthrough = passthrough.jpeg({ quality: baseQuality, mozjpeg: true });
348
+ } else if (srcFmt === "png") {
349
+ passthrough = passthrough.png({ compressionLevel: 9 });
350
+ } else if (srcFmt === "webp") {
351
+ passthrough = passthrough.webp({ quality: baseQuality, effort: 4 });
352
+ } else {
353
+ passthrough = passthrough.avif({
354
+ quality: baseQuality,
355
+ effort: retryEffort
356
+ });
357
+ }
358
+ let passthroughOut = await passthrough.toBuffer({ resolveWithObject: true });
359
+ if (srcFmt === "jpeg" && passthroughOut.info.size > originalSize) {
360
+ passthroughOut = await pipeline.clone().jpeg({ quality: 10 }).toBuffer({ resolveWithObject: true });
361
+ }
391
362
  return {
392
- buffer,
393
- width: metadata.width ?? maxDimensions.width,
394
- height: metadata.height ?? maxDimensions.height,
363
+ buffer: passthroughOut.data,
364
+ width: passthroughOut.info.width || orientedWidth || maxDimensions.width,
365
+ height: passthroughOut.info.height || orientedHeight || maxDimensions.height,
395
366
  format: srcFmt,
396
- size: originalSize
367
+ size: passthroughOut.info.size,
368
+ quality: baseQuality
397
369
  };
398
370
  }
399
371
  const webpBuffer = await pipeline.clone().webp({ quality: baseQuality, effort: 6, smartSubsample: true }).toBuffer({ resolveWithObject: true });
400
372
  const useWebP = webpBuffer.info.size <= avifRetry.info.size;
401
- const winner = useWebP ? { buf: webpBuffer.data, fmt: "webp", sz: webpBuffer.info.size } : { buf: avifRetry.data, fmt: "avif", sz: avifRetry.info.size };
402
- const winnerMeta = await sharp(winner.buf).metadata();
373
+ const winner = useWebP ? {
374
+ buf: webpBuffer.data,
375
+ fmt: "webp",
376
+ sz: webpBuffer.info.size,
377
+ w: webpBuffer.info.width,
378
+ h: webpBuffer.info.height,
379
+ q: baseQuality
380
+ } : {
381
+ buf: avifRetry.data,
382
+ fmt: "avif",
383
+ sz: avifRetry.info.size,
384
+ w: avifRetry.info.width,
385
+ h: avifRetry.info.height,
386
+ q: scaledQuality
387
+ };
403
388
  return {
404
389
  buffer: winner.buf,
405
- width: winnerMeta.width ?? maxDimensions.width,
406
- height: winnerMeta.height ?? maxDimensions.height,
390
+ width: winner.w || maxDimensions.width,
391
+ height: winner.h || maxDimensions.height,
407
392
  format: winner.fmt,
408
- size: winner.sz
393
+ size: winner.sz,
394
+ quality: winner.q
409
395
  };
410
396
  }
411
397
 
412
- // src/utils/color.ts
413
- function hexToRgb(hex) {
414
- const normalized = hex.replace("#", "");
415
- const expanded = normalized.length === 3 ? normalized.split("").map((char) => char + char).join("") : normalized;
416
- const padded = expanded.padEnd(6, "0").slice(0, 6);
417
- const value = Number.parseInt(padded, 16);
418
- if (Number.isNaN(value)) {
419
- return { r: 245, g: 245, b: 245 };
420
- }
421
- return {
422
- r: value >> 16 & 255,
423
- g: value >> 8 & 255,
424
- b: value & 255
425
- };
426
- }
427
- function rgbToHex(r, g, b) {
428
- const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
429
- const value = clamp(r) << 16 | clamp(g) << 8 | clamp(b);
430
- return `#${value.toString(16).padStart(6, "0").toUpperCase()}`;
431
- }
398
+ // src/server/optimization/blurGenerator.ts
399
+ import { encode as encodeBlurHash } from "blurhash";
400
+
401
+ // src/server/optimization/colorHistogram.ts
432
402
  async function extractEnhancedColorFromBuffer(buffer, mode = "balanced") {
403
+ const sharp = await loadSharp();
433
404
  try {
434
405
  const metadata = await sharp(buffer).metadata();
435
406
  const maxSize = 400;
@@ -574,6 +545,7 @@ function extractDominantColorHistogram(data) {
574
545
 
575
546
  // src/server/optimization/edgeColorExtractor.ts
576
547
  async function extractEdgeColorsFromBuffer(buffer) {
548
+ const sharp = await loadSharp();
577
549
  try {
578
550
  const sample = await sharp(buffer).resize(400, 400, {
579
551
  fit: "inside",
@@ -715,16 +687,26 @@ function blendEdgeColors(edgeColors) {
715
687
  }
716
688
 
717
689
  // src/server/optimization/blurGenerator.ts
690
+ function encodeRawBlurHash(data, width, height, componentX = 4, componentY = 3) {
691
+ const clampedX = Math.max(1, Math.min(Math.round(componentX), width, 9));
692
+ const clampedY = Math.max(1, Math.min(Math.round(componentY), height, 9));
693
+ return encodeBlurHash(
694
+ new Uint8ClampedArray(data),
695
+ width,
696
+ height,
697
+ clampedX,
698
+ clampedY
699
+ );
700
+ }
718
701
  async function encodeBlurHashString(buffer, componentX = 4, componentY = 3) {
702
+ const sharp = await loadSharp();
719
703
  const { data, info } = await sharp(buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
720
- const clampedX = Math.max(1, Math.min(Math.round(componentX), info.width, 9));
721
- const clampedY = Math.max(1, Math.min(Math.round(componentY), info.height, 9));
722
- return encode(
723
- new Uint8ClampedArray(data),
704
+ return encodeRawBlurHash(
705
+ data,
724
706
  info.width,
725
707
  info.height,
726
- clampedX,
727
- clampedY
708
+ componentX,
709
+ componentY
728
710
  );
729
711
  }
730
712
  async function generateBlurPlaceholder(buffer, options = {}) {
@@ -732,29 +714,46 @@ async function generateBlurPlaceholder(buffer, options = {}) {
732
714
  const fallbackWidth = options.fallbackWidth ?? BLUR_PLACEHOLDER_FALLBACK_WIDTH;
733
715
  const quality = options.quality ?? BLUR_PLACEHOLDER_QUALITY;
734
716
  const maxChars = options.maxDataUrlChars ?? BLUR_PLACEHOLDER_MAX_CHARS;
717
+ const sharp = await loadSharp();
735
718
  try {
736
719
  let processBuffer = buffer;
737
720
  const isSvg = /<svg[\s>]/i.test(buffer.toString("utf8", 0, 1024));
738
721
  if (isSvg) {
739
722
  const normalizedSvg = buffer.toString("utf8").replace(/<svg\b/gi, "<svg").replace(/<\/svg\s*>/gi, "</svg>");
740
- processBuffer = await sharp(Buffer.from(normalizedSvg, "utf8")).resize(targetWidth, targetWidth, { fit: "inside" }).png().toBuffer();
723
+ processBuffer = Buffer.from(normalizedSvg, "utf8");
741
724
  }
742
- const buildPlaceholder = (width, q) => lqip(processBuffer, {
743
- outputFormat: "webp",
744
- resize: width,
745
- outputOptions: { quality: q, effort: 6, smartSubsample: true }
746
- });
747
- let result = await buildPlaceholder(targetWidth, quality);
748
- if (result.metadata.dataURIBase64.length > maxChars) {
749
- result = await buildPlaceholder(fallbackWidth, quality);
725
+ const baseSize = Math.max(targetWidth, 128);
726
+ const rawSample = await sharp(processBuffer).rotate().resize(baseSize, baseSize, { fit: "inside", withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
727
+ const rawInput = {
728
+ raw: {
729
+ width: rawSample.info.width,
730
+ height: rawSample.info.height,
731
+ channels: 4
732
+ }
733
+ };
734
+ const buildPlaceholderDataUrl = async (width) => {
735
+ const webpBuffer = await sharp(rawSample.data, rawInput).resize(width, width, { fit: "inside", withoutEnlargement: true }).webp({ quality, effort: 6, smartSubsample: true }).toBuffer();
736
+ return `data:image/webp;base64,${webpBuffer.toString("base64")}`;
737
+ };
738
+ let dataURL = await buildPlaceholderDataUrl(targetWidth);
739
+ if (dataURL.length > maxChars) {
740
+ dataURL = await buildPlaceholderDataUrl(fallbackWidth);
750
741
  }
751
- const dataURL = result.metadata.dataURIBase64;
752
- const edgeColors = await extractEdgeColorsFromBuffer(processBuffer);
742
+ const edgeColors = computeEdgeColorSet(
743
+ rawSample.data,
744
+ rawSample.info.width,
745
+ rawSample.info.height
746
+ );
753
747
  const color = blendEdgeColors(edgeColors);
754
748
  let blurHash;
755
749
  if (options.includeBlurHash) {
756
750
  try {
757
- blurHash = await encodeBlurHashString(processBuffer);
751
+ const hashSample = rawSample.info.width <= 32 && rawSample.info.height <= 32 ? rawSample : await sharp(rawSample.data, rawInput).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
752
+ blurHash = encodeRawBlurHash(
753
+ hashSample.data,
754
+ hashSample.info.width,
755
+ hashSample.info.height
756
+ );
758
757
  } catch {
759
758
  blurHash = void 0;
760
759
  }
@@ -770,6 +769,7 @@ async function generateBlurPlaceholder(buffer, options = {}) {
770
769
  }
771
770
  var generateBlurHash = generateBlurPlaceholder;
772
771
  async function generateFallbackBlur(buffer, includeBlurHash) {
772
+ const sharp = await loadSharp();
773
773
  const tinyBuffer = await sharp(buffer).resize(64, 64, { fit: "cover", kernel: "lanczos3" }).blur(1).webp({
774
774
  quality: 75,
775
775
  effort: 6,
@@ -789,51 +789,278 @@ async function generateFallbackBlur(buffer, includeBlurHash) {
789
789
  return { dataURL, color, ...blurHash && { blurHash } };
790
790
  }
791
791
 
792
+ // src/server/security/sniffFormat.ts
793
+ var AVIF_BRANDS = /* @__PURE__ */ new Set(["avif", "avis"]);
794
+ var HEIF_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "hevc", "hevx", "mif1", "msf1"]);
795
+ function readAscii(bytes, start, end) {
796
+ let out = "";
797
+ const limit = Math.min(bytes.length, end);
798
+ for (let i = start; i < limit; i++) {
799
+ out += String.fromCharCode(bytes[i]);
800
+ }
801
+ return out;
802
+ }
803
+ function sniffIsoBmff(bytes) {
804
+ if (bytes.length < 12) return null;
805
+ if (readAscii(bytes, 4, 8) !== "ftyp") return null;
806
+ const boxSize = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
807
+ const maxOffset = Math.min(
808
+ bytes.length,
809
+ boxSize >= 16 ? Math.min(boxSize, 128) : 64
810
+ );
811
+ const majorBrand = readAscii(bytes, 8, 12).toLowerCase();
812
+ if (AVIF_BRANDS.has(majorBrand)) {
813
+ return { format: "avif", mime: "image/avif" };
814
+ }
815
+ const brands = [majorBrand];
816
+ for (let offset = 16; offset + 4 <= maxOffset; offset += 4) {
817
+ brands.push(readAscii(bytes, offset, offset + 4).toLowerCase());
818
+ }
819
+ if (brands.some((b) => AVIF_BRANDS.has(b))) {
820
+ return { format: "avif", mime: "image/avif" };
821
+ }
822
+ if (brands.some((b) => HEIF_BRANDS.has(b))) {
823
+ return { format: "heif", mime: "image/heif" };
824
+ }
825
+ return null;
826
+ }
827
+ function sniffTextSvg(bytes) {
828
+ const sampleLength = Math.min(bytes.length, 4096);
829
+ for (let i = 0; i < Math.min(sampleLength, 512); i++) {
830
+ if (bytes[i] === 0) return null;
831
+ }
832
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, sampleLength)).replace(/^\uFEFF/, "").trimStart();
833
+ if (!text.startsWith("<")) return null;
834
+ const stripped = text.replace(/^<\?xml[\s\S]*?\?>\s*/i, "").replace(/<!--[\s\S]*?-->\s*/g, "").replace(/<!DOCTYPE\s+svg[\s\S]*?(?:\]>|>)\s*/i, "").replace(/<!--[\s\S]*?-->\s*/g, "").trimStart();
835
+ if (/^<svg[\s>/]/i.test(stripped) || /<svg[\s>/]/i.test(text)) {
836
+ if (/^<!doctype\s+html\b/i.test(text) || /^<html[\s>]/i.test(stripped)) {
837
+ return null;
838
+ }
839
+ return { format: "svg", mime: "image/svg+xml" };
840
+ }
841
+ return null;
842
+ }
843
+ function sniffImageFormat(input) {
844
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
845
+ if (bytes.length < 4) return null;
846
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
847
+ return { format: "jpeg", mime: "image/jpeg" };
848
+ }
849
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) {
850
+ return { format: "png", mime: "image/png" };
851
+ }
852
+ if (bytes.length >= 6) {
853
+ const header = readAscii(bytes, 0, 6);
854
+ if (header === "GIF87a" || header === "GIF89a") {
855
+ return { format: "gif", mime: "image/gif" };
856
+ }
857
+ }
858
+ if (bytes.length >= 12 && readAscii(bytes, 0, 4) === "RIFF" && readAscii(bytes, 8, 12) === "WEBP") {
859
+ return { format: "webp", mime: "image/webp" };
860
+ }
861
+ const bmff = sniffIsoBmff(bytes);
862
+ if (bmff) return bmff;
863
+ if (bytes[0] === 73 && bytes[1] === 73 && bytes[2] === 42 && bytes[3] === 0 || bytes[0] === 77 && bytes[1] === 77 && bytes[2] === 0 && bytes[3] === 42) {
864
+ return { format: "tiff", mime: "image/tiff" };
865
+ }
866
+ if (bytes.length >= 14 && bytes[0] === 66 && bytes[1] === 77) {
867
+ return { format: "bmp", mime: "image/bmp" };
868
+ }
869
+ if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) {
870
+ return { format: "ico", mime: "image/x-icon" };
871
+ }
872
+ return sniffTextSvg(bytes);
873
+ }
874
+ function assertMagicBytesAllowed(buffer, allowedFormats, allowSvg = false) {
875
+ const sniffed = sniffImageFormat(buffer);
876
+ if (!sniffed) {
877
+ throw new UploadValidationError(
878
+ "Unsupported image format: file content is not a recognized image"
879
+ );
880
+ }
881
+ if (sniffed.format === "svg" && !allowSvg) {
882
+ throw new UploadValidationError(
883
+ "File type image/svg+xml is not allowed unless allowSvg: true is set"
884
+ );
885
+ }
886
+ if (allowedFormats.length > 0 && !allowedFormats.includes(sniffed.mime)) {
887
+ throw new UploadValidationError(
888
+ `File type ${sniffed.mime} is not allowed. Allowed: ${allowedFormats.join(", ")}`
889
+ );
890
+ }
891
+ return sniffed;
892
+ }
893
+
792
894
  // src/server/semaphore.ts
895
+ var SemaphoreQueueFullError = class extends Error {
896
+ status = 503;
897
+ constructor(message = "Server upload queue is full. Please try again later.") {
898
+ super(message);
899
+ this.name = "SemaphoreQueueFullError";
900
+ }
901
+ };
902
+ function createAbortError(signal) {
903
+ if (signal?.reason instanceof Error) {
904
+ return signal.reason;
905
+ }
906
+ const err = new Error(
907
+ typeof signal?.reason === "string" ? signal.reason : "Operation aborted"
908
+ );
909
+ err.name = "AbortError";
910
+ return err;
911
+ }
793
912
  var AsyncSemaphore = class {
794
- constructor(concurrency) {
913
+ constructor(concurrency, options) {
795
914
  this.concurrency = concurrency;
796
915
  if (concurrency < 1) {
797
916
  throw new Error("AsyncSemaphore concurrency must be >= 1");
798
917
  }
918
+ if (typeof options === "number") {
919
+ this.maxQueue = options;
920
+ } else if (options && typeof options === "object") {
921
+ this.maxQueue = options.maxQueue;
922
+ }
799
923
  }
800
924
  concurrency;
801
925
  activeCount = 0;
926
+ maxQueue;
802
927
  queue = [];
803
- async run(task) {
804
- await this.acquire();
928
+ get active() {
929
+ return this.activeCount;
930
+ }
931
+ get pending() {
932
+ return this.queue.length;
933
+ }
934
+ async run(task, options = {}) {
935
+ const { signal, timeoutMs, label = "Semaphore task" } = options;
936
+ await this.acquire(signal);
937
+ let rawTaskPromise;
805
938
  try {
806
- return await task();
807
- } finally {
939
+ rawTaskPromise = Promise.resolve().then(task);
940
+ } catch (syncError) {
808
941
  this.release();
942
+ throw syncError;
943
+ }
944
+ let released = false;
945
+ const releaseOnce = () => {
946
+ if (!released) {
947
+ released = true;
948
+ this.release();
949
+ }
950
+ };
951
+ void rawTaskPromise.then(
952
+ () => releaseOnce(),
953
+ (err) => {
954
+ const pending = err && typeof err === "object" && "pendingTask" in err ? err.pendingTask : void 0;
955
+ if (pending && typeof pending.then === "function") {
956
+ void pending.then(releaseOnce, releaseOnce);
957
+ } else {
958
+ releaseOnce();
959
+ }
960
+ }
961
+ );
962
+ let callerPromise = rawTaskPromise;
963
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
964
+ callerPromise = withTimeout(callerPromise, timeoutMs, label);
965
+ }
966
+ if (signal) {
967
+ callerPromise = raceWithAbort(callerPromise, signal);
809
968
  }
969
+ return callerPromise;
810
970
  }
811
- acquire() {
971
+ acquire(signal) {
972
+ if (signal?.aborted) {
973
+ return Promise.reject(createAbortError(signal));
974
+ }
812
975
  if (this.activeCount < this.concurrency) {
813
976
  this.activeCount += 1;
814
977
  return Promise.resolve();
815
978
  }
816
- return new Promise((resolve) => {
817
- this.queue.push(() => {
818
- this.activeCount += 1;
819
- resolve();
820
- });
979
+ if (this.maxQueue !== void 0 && this.queue.length >= this.maxQueue) {
980
+ return Promise.reject(new SemaphoreQueueFullError());
981
+ }
982
+ return new Promise((resolve, reject) => {
983
+ const onAbort = () => {
984
+ const idx = this.queue.indexOf(entry);
985
+ if (idx !== -1) {
986
+ this.queue.splice(idx, 1);
987
+ }
988
+ entry.cancel(createAbortError(signal));
989
+ };
990
+ const entry = {
991
+ grant: () => {
992
+ signal?.removeEventListener("abort", onAbort);
993
+ this.activeCount += 1;
994
+ resolve();
995
+ },
996
+ cancel: (reason) => {
997
+ signal?.removeEventListener("abort", onAbort);
998
+ reject(reason);
999
+ }
1000
+ };
1001
+ signal?.addEventListener("abort", onAbort, { once: true });
1002
+ this.queue.push(entry);
821
1003
  });
822
1004
  }
823
1005
  release() {
824
1006
  this.activeCount = Math.max(0, this.activeCount - 1);
825
1007
  const next = this.queue.shift();
826
- next?.();
1008
+ next?.grant();
827
1009
  }
828
1010
  };
1011
+ function raceWithAbort(task, signal) {
1012
+ if (signal.aborted) {
1013
+ const err = createAbortError(signal);
1014
+ err.pendingTask = task.then(
1015
+ () => {
1016
+ },
1017
+ () => {
1018
+ }
1019
+ );
1020
+ return Promise.reject(err);
1021
+ }
1022
+ return new Promise((resolve, reject) => {
1023
+ const onAbort = () => {
1024
+ const err = createAbortError(signal);
1025
+ err.pendingTask = task.then(
1026
+ () => {
1027
+ },
1028
+ () => {
1029
+ }
1030
+ );
1031
+ reject(err);
1032
+ };
1033
+ signal.addEventListener("abort", onAbort, { once: true });
1034
+ task.then(
1035
+ (val) => {
1036
+ signal.removeEventListener("abort", onAbort);
1037
+ resolve(val);
1038
+ },
1039
+ (err) => {
1040
+ signal.removeEventListener("abort", onAbort);
1041
+ reject(err);
1042
+ }
1043
+ );
1044
+ });
1045
+ }
829
1046
  async function withTimeout(task, timeoutMs, label) {
830
1047
  let timeoutHandle = null;
1048
+ const settledTask = task.then(
1049
+ () => {
1050
+ },
1051
+ () => {
1052
+ }
1053
+ );
831
1054
  try {
832
1055
  return await Promise.race([
833
1056
  task,
834
1057
  new Promise((_, reject) => {
835
1058
  timeoutHandle = setTimeout(() => {
836
- reject(new Error(`${label} timed out after ${timeoutMs}ms`));
1059
+ const err = new Error(
1060
+ `${label} timed out after ${timeoutMs}ms`
1061
+ );
1062
+ err.pendingTask = settledTask;
1063
+ reject(err);
837
1064
  }, timeoutMs);
838
1065
  })
839
1066
  ]);
@@ -843,15 +1070,36 @@ async function withTimeout(task, timeoutMs, label) {
843
1070
  }
844
1071
  }
845
1072
  }
1073
+
1074
+ // src/server/uploader/bufferProcessor.ts
1075
+ async function computeSha256Hex(buffer) {
1076
+ const bytes = new Uint8Array(buffer.byteLength);
1077
+ bytes.set(buffer);
1078
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
1079
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1080
+ }
1081
+ function throwIfAborted(signal) {
1082
+ if (!signal?.aborted) return;
1083
+ if (signal.reason instanceof Error) throw signal.reason;
1084
+ const err = new Error(
1085
+ typeof signal.reason === "string" ? signal.reason : "Operation aborted"
1086
+ );
1087
+ err.name = "AbortError";
1088
+ throw err;
1089
+ }
846
1090
  async function processImageBuffer(originalBuffer, options = {}) {
847
1091
  const optTimeout = options.optimizationTimeoutMs ?? 6e4;
848
1092
  const blurTimeout = options.blurTimeoutMs ?? 3e4;
849
- const contentHash = crypto.createHash("sha256").update(originalBuffer).digest("hex");
1093
+ throwIfAborted(options.signal);
1094
+ const contentHash = await computeSha256Hex(originalBuffer);
1095
+ throwIfAborted(options.signal);
850
1096
  const optimized = await withTimeout(
851
1097
  optimizeImage(originalBuffer, {
852
1098
  maxDimensions: options.maxDimensions,
853
1099
  outputFormat: options.outputFormat,
854
- quality: options.quality
1100
+ quality: options.quality,
1101
+ limitInputPixels: options.limitInputPixels,
1102
+ effort: options.effort
855
1103
  }),
856
1104
  optTimeout,
857
1105
  "Image optimization"
@@ -864,6 +1112,7 @@ async function processImageBuffer(originalBuffer, options = {}) {
864
1112
  `Image dimensions missing: width=${optimized.width}, height=${optimized.height}`
865
1113
  );
866
1114
  }
1115
+ throwIfAborted(options.signal);
867
1116
  const blurData = await withTimeout(
868
1117
  generateBlurPlaceholder(optimized.buffer, {
869
1118
  includeBlurHash: options.includeBlurHash
@@ -879,22 +1128,12 @@ async function processImageBuffer(originalBuffer, options = {}) {
879
1128
  };
880
1129
  }
881
1130
 
882
- // src/utils/validators.ts
883
- function validateImageFileStrict(file, maxSizeMB, allowedFormats) {
884
- const maxBytes = maxSizeMB * 1024 * 1024;
885
- if (file.size > maxBytes) {
886
- return {
887
- valid: false,
888
- error: `File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds maximum of ${maxSizeMB}MB`
889
- };
1131
+ // src/server/uploader/pathGenerator.ts
1132
+ function randomShortId() {
1133
+ if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
1134
+ return globalThis.crypto.randomUUID().split("-")[0];
890
1135
  }
891
- if (allowedFormats.length > 0 && !allowedFormats.includes(file.type)) {
892
- return {
893
- valid: false,
894
- error: `File type ${file.type || "unknown"} is not allowed. Allowed: ${allowedFormats.join(", ")}`
895
- };
896
- }
897
- return { valid: true };
1136
+ return Math.random().toString(16).slice(2, 10).padEnd(8, "0");
898
1137
  }
899
1138
  function formatToExtension(format) {
900
1139
  switch (format) {
@@ -913,16 +1152,20 @@ function formatToExtension(format) {
913
1152
  function sanitizeSegment(value) {
914
1153
  return value.replace(/\\/g, "/").split("/").map((part) => part.trim().replace(/[^a-zA-Z0-9._-]/g, "-")).filter((part) => part && part !== "." && part !== "..").join("/");
915
1154
  }
1155
+ function stripFilenameExtension(rawFilename) {
1156
+ const leaf = rawFilename.replace(/^.*[\\/]/, "").trim();
1157
+ const dotIdx = leaf.lastIndexOf(".");
1158
+ const base = dotIdx > 0 ? leaf.slice(0, dotIdx) : leaf;
1159
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^[.-]+|[.-]+$/g, "");
1160
+ return cleaned || "image";
1161
+ }
916
1162
  function createDefaultPathGenerator() {
917
1163
  let imageCounter = 0;
918
1164
  return (ctx) => {
919
1165
  const cleanFolder = ctx.folder ? sanitizeSegment(ctx.folder) : "";
920
1166
  if (ctx.customFilename?.trim()) {
921
- let cleanName = ctx.customFilename.replace(/^.*[\\/]/, "").trim();
922
- if (!cleanName.includes(".")) {
923
- cleanName = `${cleanName}.${ctx.extension}`;
924
- }
925
- cleanName = cleanName.replace(/[^a-zA-Z0-9._-]/g, "-");
1167
+ const baseName = stripFilenameExtension(ctx.customFilename);
1168
+ const cleanName = `${baseName}.${ctx.extension}`;
926
1169
  const fullPath2 = cleanFolder ? `${cleanFolder}/${cleanName}` : cleanName;
927
1170
  return {
928
1171
  bucket: ctx.bucket,
@@ -937,7 +1180,7 @@ function createDefaultPathGenerator() {
937
1180
  imageCounter = (imageCounter + 1) % 1e4;
938
1181
  const seq = String(imageCounter).padStart(4, "0");
939
1182
  const timestamp = Date.now();
940
- const randomSuffix = crypto.randomUUID().split("-")[0];
1183
+ const randomSuffix = randomShortId();
941
1184
  const prefix = sanitizeSegment(
942
1185
  ctx.preset || cleanFolder.split("/")[0] || "img"
943
1186
  );
@@ -950,10 +1193,21 @@ function createDefaultPathGenerator() {
950
1193
  };
951
1194
  };
952
1195
  }
1196
+
1197
+ // src/server/uploader/uploadRetry.ts
1198
+ import fs from "fs/promises";
1199
+ import path from "path";
1200
+ function randomRetrySuffix() {
1201
+ if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
1202
+ return globalThis.crypto.randomUUID().split("-")[0];
1203
+ }
1204
+ return Math.random().toString(16).slice(2, 10);
1205
+ }
953
1206
  function isCollisionError(e) {
954
1207
  if (!e) return false;
955
1208
  const code = typeof e === "object" && e !== null && "code" in e ? String(e.code).toUpperCase() : "";
956
- if (code === "EEXIST") {
1209
+ const statusCode = typeof e === "object" && e !== null && "$metadata" in e ? e.$metadata?.httpStatusCode : typeof e === "object" && e !== null && "statusCode" in e ? Number(e.statusCode) : void 0;
1210
+ if (code === "EEXIST" || code === "PRECONDITIONFAILED" || statusCode === 412) {
957
1211
  return true;
958
1212
  }
959
1213
  if (code === "ENOENT") {
@@ -963,7 +1217,7 @@ function isCollisionError(e) {
963
1217
  if (msg.includes("does not exist") || msg.includes("doesn't exist") || msg.includes("not exist") || msg.includes("enoent")) {
964
1218
  return false;
965
1219
  }
966
- return msg.includes("already exists") || msg.includes("already_exists") || msg.includes("eexist") || msg.includes("file exists");
1220
+ return msg.includes("already exists") || msg.includes("already_exists") || msg.includes("eexist") || msg.includes("file exists") || msg.includes("precondition failed");
967
1221
  }
968
1222
  async function uploadWithCollisionRetry(params) {
969
1223
  const {
@@ -974,6 +1228,7 @@ async function uploadWithCollisionRetry(params) {
974
1228
  contentType,
975
1229
  maxRetries = 3
976
1230
  } = params;
1231
+ const isServerCustomName = Boolean(pathContext.customFilename) && !pathContext.isClientFilename;
977
1232
  let storageTarget = buildPath(pathContext);
978
1233
  let uploadResult;
979
1234
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -983,14 +1238,14 @@ async function uploadWithCollisionRetry(params) {
983
1238
  path: storageTarget.path,
984
1239
  data,
985
1240
  contentType,
986
- upsert: Boolean(pathContext.customFilename),
1241
+ upsert: isServerCustomName,
987
1242
  contentHash: pathContext.contentHash,
988
1243
  metadata: pathContext.metadata
989
1244
  });
990
1245
  break;
991
1246
  } catch (e) {
992
1247
  if (isCollisionError(e)) {
993
- if (pathContext.customFilename) {
1248
+ if (isServerCustomName) {
994
1249
  uploadResult = await storage.upload({
995
1250
  bucket: storageTarget.bucket,
996
1251
  path: storageTarget.path,
@@ -1002,6 +1257,14 @@ async function uploadWithCollisionRetry(params) {
1002
1257
  });
1003
1258
  break;
1004
1259
  }
1260
+ if (pathContext.customFilename && pathContext.isClientFilename) {
1261
+ const base = stripFilenameExtension(pathContext.customFilename);
1262
+ storageTarget = buildPath({
1263
+ ...pathContext,
1264
+ customFilename: `${base}-${randomRetrySuffix()}`
1265
+ });
1266
+ continue;
1267
+ }
1005
1268
  storageTarget = buildPath({
1006
1269
  ...pathContext,
1007
1270
  customFilename: void 0
@@ -1016,23 +1279,233 @@ async function uploadWithCollisionRetry(params) {
1016
1279
  }
1017
1280
  return { uploadResult, storageTarget };
1018
1281
  }
1019
- function mirrorBufferLocally(options) {
1020
- const { buffer, filename, fallbackUrl, mirrorDir, publicUrlBase } = options;
1282
+ async function mirrorBufferLocally(options) {
1283
+ const {
1284
+ buffer,
1285
+ filename,
1286
+ storagePath,
1287
+ fallbackUrl,
1288
+ mirrorDir,
1289
+ publicUrlBase
1290
+ } = options;
1021
1291
  if (!mirrorDir) {
1022
1292
  return fallbackUrl;
1023
1293
  }
1294
+ const relativeTarget = (storagePath || filename).replace(/^\/+/, "");
1024
1295
  try {
1025
- if (!fs.existsSync(mirrorDir)) {
1026
- fs.mkdirSync(mirrorDir, { recursive: true });
1296
+ const resolvedBase = path.resolve(mirrorDir);
1297
+ const destPath = path.resolve(resolvedBase, relativeTarget);
1298
+ const rel = path.relative(resolvedBase, destPath);
1299
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
1300
+ return fallbackUrl;
1027
1301
  }
1028
- fs.writeFileSync(path.join(mirrorDir, filename), buffer);
1302
+ const destDir = path.dirname(destPath);
1303
+ await fs.mkdir(destDir, { recursive: true });
1304
+ await fs.writeFile(destPath, buffer);
1029
1305
  if (publicUrlBase) {
1030
- return `${publicUrlBase.replace(/\/+$/, "")}/${filename}`;
1306
+ return `${publicUrlBase.replace(/\/+$/, "")}/${relativeTarget}`;
1031
1307
  }
1032
1308
  } catch {
1033
1309
  }
1034
1310
  return fallbackUrl;
1035
1311
  }
1312
+ async function deleteMirroredFiles(mirrorDirs, storagePath) {
1313
+ if (!mirrorDirs.length || !storagePath) return;
1314
+ const relativeTarget = storagePath.replace(/^\/+/, "");
1315
+ if (!relativeTarget) return;
1316
+ await Promise.all(
1317
+ mirrorDirs.map(async (dir) => {
1318
+ try {
1319
+ const resolvedBase = path.resolve(dir);
1320
+ const destPath = path.resolve(resolvedBase, relativeTarget);
1321
+ const rel = path.relative(resolvedBase, destPath);
1322
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1323
+ await fs.unlink(destPath);
1324
+ } catch {
1325
+ }
1326
+ })
1327
+ );
1328
+ }
1329
+
1330
+ // src/server/uploader/variantGenerator.ts
1331
+ function buildVariantStoragePath(basePath, width) {
1332
+ const dotIdx = basePath.lastIndexOf(".");
1333
+ const slashIdx = basePath.lastIndexOf("/");
1334
+ if (dotIdx > slashIdx && dotIdx > 0) {
1335
+ return `${basePath.slice(0, dotIdx)}-${width}w${basePath.slice(dotIdx)}`;
1336
+ }
1337
+ return `${basePath}-${width}w`;
1338
+ }
1339
+ function resolveEffectiveAllowedFormats(baseFormats, allowSvg, explicitAllowSvg) {
1340
+ if (!allowSvg) return baseFormats.filter((fmt) => fmt !== SVG_MIME_TYPE);
1341
+ if (explicitAllowSvg && !baseFormats.includes(SVG_MIME_TYPE)) {
1342
+ return [...baseFormats, SVG_MIME_TYPE];
1343
+ }
1344
+ return baseFormats;
1345
+ }
1346
+ async function generateImageVariants(params) {
1347
+ const {
1348
+ sourceBuffer,
1349
+ format,
1350
+ quality,
1351
+ effort,
1352
+ limitInputPixels,
1353
+ mainWidth,
1354
+ mainHeight,
1355
+ mainUrl,
1356
+ baseStoragePath,
1357
+ baseFilename,
1358
+ bucket,
1359
+ storage,
1360
+ variantWidths,
1361
+ upsert = false,
1362
+ contentHash,
1363
+ metadata,
1364
+ mirrorDir,
1365
+ publicUrlBase
1366
+ } = params;
1367
+ if (format === "svg" || !variantWidths?.length) {
1368
+ return { variants: [] };
1369
+ }
1370
+ const targetWidths = [
1371
+ ...new Set(
1372
+ variantWidths.map((w) => Math.floor(w)).filter((w) => Number.isFinite(w) && w > 0 && w < mainWidth)
1373
+ )
1374
+ ].sort((a, b) => a - b);
1375
+ if (targetWidths.length === 0) {
1376
+ return { variants: [] };
1377
+ }
1378
+ const sharp = await loadSharp();
1379
+ const baseQuality = Math.max(1, Math.min(100, quality ?? 85));
1380
+ const avifEffort = effort ?? PRIMARY_AVIF_EFFORT;
1381
+ const basePipeline = sharp(sourceBuffer, {
1382
+ failOn: "error",
1383
+ limitInputPixels: limitInputPixels ?? DEFAULT_LIMIT_INPUT_PIXELS
1384
+ }).rotate();
1385
+ const variants = await Promise.all(
1386
+ targetWidths.map(async (w) => {
1387
+ let branch = basePipeline.clone().resize({
1388
+ width: w,
1389
+ withoutEnlargement: true,
1390
+ kernel: "lanczos3"
1391
+ });
1392
+ if (format === "jpeg") {
1393
+ branch = branch.jpeg({ quality: baseQuality, mozjpeg: true });
1394
+ } else if (format === "png") {
1395
+ branch = branch.png({ compressionLevel: 9 });
1396
+ } else if (format === "webp") {
1397
+ branch = branch.webp({ quality: baseQuality, effort: 4 });
1398
+ } else {
1399
+ branch = branch.avif({
1400
+ quality: baseQuality,
1401
+ effort: avifEffort,
1402
+ chromaSubsampling: "4:2:0",
1403
+ bitdepth: 8
1404
+ });
1405
+ }
1406
+ const out = await branch.toBuffer({ resolveWithObject: true });
1407
+ const variantPath = buildVariantStoragePath(baseStoragePath, w);
1408
+ const variantFilename = buildVariantStoragePath(baseFilename, w);
1409
+ const uploaded = await storage.upload({
1410
+ bucket,
1411
+ path: variantPath,
1412
+ data: out.data,
1413
+ contentType: `image/${format}`,
1414
+ upsert,
1415
+ contentHash,
1416
+ metadata
1417
+ });
1418
+ const resolvedUrl = await mirrorBufferLocally({
1419
+ buffer: out.data,
1420
+ filename: variantFilename,
1421
+ storagePath: uploaded.path,
1422
+ fallbackUrl: uploaded.url,
1423
+ mirrorDir,
1424
+ publicUrlBase
1425
+ });
1426
+ return {
1427
+ width: out.info.width || w,
1428
+ height: out.info.height || Math.max(1, Math.round(mainHeight / Math.max(1, mainWidth) * w)),
1429
+ url: resolvedUrl,
1430
+ size: out.info.size,
1431
+ storagePath: uploaded.path
1432
+ };
1433
+ })
1434
+ );
1435
+ const srcSet = [
1436
+ ...variants.map((v) => `${v.url} ${v.width}w`),
1437
+ `${mainUrl} ${mainWidth}w`
1438
+ ].join(", ");
1439
+ return { variants, srcSet };
1440
+ }
1441
+ function collectCandidateVariantWidths(presets, extraWidths) {
1442
+ const widths = new Set(extraWidths);
1443
+ for (const preset of Object.values(presets)) {
1444
+ if (preset?.variantWidths) {
1445
+ for (const w of preset.variantWidths) {
1446
+ if (Number.isFinite(w) && w > 0) widths.add(Math.floor(w));
1447
+ }
1448
+ }
1449
+ }
1450
+ return [...widths];
1451
+ }
1452
+ async function deleteStoredVariants(params) {
1453
+ const {
1454
+ storage,
1455
+ bucket,
1456
+ basePath,
1457
+ mirrorDirs,
1458
+ knownVariantPaths = [],
1459
+ candidateWidths = []
1460
+ } = params;
1461
+ const paths = new Set(knownVariantPaths);
1462
+ for (const w of candidateWidths) {
1463
+ paths.add(buildVariantStoragePath(basePath, w));
1464
+ }
1465
+ if (paths.size === 0) return;
1466
+ await Promise.all(
1467
+ [...paths].map(async (vPath) => {
1468
+ await storage.delete(bucket, vPath).catch(() => {
1469
+ });
1470
+ await deleteMirroredFiles(mirrorDirs, vPath);
1471
+ })
1472
+ );
1473
+ }
1474
+ async function invokeRecordHookWithRollback(params) {
1475
+ const {
1476
+ onUploadRecord,
1477
+ awaitRecordHook,
1478
+ record,
1479
+ storage,
1480
+ bucket,
1481
+ mainPath,
1482
+ variantPaths,
1483
+ mirrorDirs,
1484
+ onError
1485
+ } = params;
1486
+ if (!onUploadRecord) return;
1487
+ if (awaitRecordHook) {
1488
+ try {
1489
+ await onUploadRecord(record);
1490
+ } catch (hookError) {
1491
+ await storage.delete(bucket, mainPath).catch(() => {
1492
+ });
1493
+ await deleteMirroredFiles(mirrorDirs, mainPath);
1494
+ await deleteStoredVariants({
1495
+ storage,
1496
+ bucket,
1497
+ basePath: mainPath,
1498
+ mirrorDirs,
1499
+ knownVariantPaths: variantPaths
1500
+ });
1501
+ throw hookError;
1502
+ }
1503
+ } else {
1504
+ Promise.resolve(onUploadRecord(record)).catch((err) => {
1505
+ onError?.(err, { action: "recordHook" });
1506
+ });
1507
+ }
1508
+ }
1036
1509
 
1037
1510
  // src/server/uploader/createImageUploader.ts
1038
1511
  function createImageUploader(options) {
@@ -1043,12 +1516,17 @@ function createImageUploader(options) {
1043
1516
  defaultMaxSizeMB = 10,
1044
1517
  defaultAllowedFormats = DEFAULT_ALLOWED_FORMATS,
1045
1518
  defaultMaxDimensions = DEFAULT_IMAGE_DIMENSIONS,
1519
+ allowSvg: defaultAllowSvg = false,
1520
+ limitInputPixels: defaultLimitInputPixels,
1521
+ effort: defaultEffort,
1046
1522
  presets: customPresets,
1047
1523
  includeStandardPresets = true,
1048
1524
  pathGenerator,
1049
1525
  onUploadRecord,
1050
1526
  awaitRecordHook = false,
1527
+ onError,
1051
1528
  concurrency = 2,
1529
+ maxQueue,
1052
1530
  optimizationTimeoutMs = 6e4,
1053
1531
  blurTimeoutMs = 3e4,
1054
1532
  includeBlurHash: defaultIncludeBlurHash = false
@@ -1057,11 +1535,47 @@ function createImageUploader(options) {
1057
1535
  ...includeStandardPresets ? STANDARD_PRESETS : {},
1058
1536
  ...customPresets || {}
1059
1537
  };
1060
- const semaphore = new AsyncSemaphore(concurrency);
1538
+ const semaphore = new AsyncSemaphore(concurrency, { maxQueue });
1061
1539
  const buildPath = pathGenerator || createDefaultPathGenerator();
1062
- const resolvePreset = (presetName) => presetName ? mergedPresets[presetName] : void 0;
1540
+ const trackedVariants = /* @__PURE__ */ new Map();
1541
+ const observedWidths = /* @__PURE__ */ new Set();
1542
+ const resolvePreset = (name) => name ? mergedPresets[name] : void 0;
1543
+ const getAllowedBuckets = () => {
1544
+ const buckets = /* @__PURE__ */ new Set([defaultBucket]);
1545
+ for (const preset of Object.values(mergedPresets)) {
1546
+ if (preset?.bucket) buckets.add(preset.bucket);
1547
+ }
1548
+ return [...buckets];
1549
+ };
1550
+ const getMirrorDirs = () => [...new Set(Object.values(mergedPresets).map((p) => p?.mirrorDir).filter(Boolean))];
1551
+ const getMaxSizeMB = (allowedPresets) => {
1552
+ let maxMB = defaultMaxSizeMB;
1553
+ const keys = allowedPresets?.length ? allowedPresets : Object.keys(mergedPresets);
1554
+ for (const key of keys) {
1555
+ const p = mergedPresets[key];
1556
+ if (p?.maxSizeMB && p.maxSizeMB > maxMB) maxMB = p.maxSizeMB;
1557
+ }
1558
+ return maxMB;
1559
+ };
1560
+ const cleanupVariantsFor = async (bucket, basePath) => {
1561
+ const key = `${bucket}:${basePath}`;
1562
+ const knownVariantPaths = trackedVariants.get(key);
1563
+ trackedVariants.delete(key);
1564
+ await deleteStoredVariants({
1565
+ storage,
1566
+ bucket,
1567
+ basePath,
1568
+ mirrorDirs: getMirrorDirs(),
1569
+ knownVariantPaths,
1570
+ candidateWidths: collectCandidateVariantWidths(mergedPresets, observedWidths)
1571
+ });
1572
+ };
1063
1573
  return {
1574
+ defaultBucket,
1064
1575
  resolvePreset,
1576
+ getAllowedBuckets,
1577
+ getMaxSizeMB,
1578
+ parseUrl: (url) => storage.parseUrl ? storage.parseUrl(url) : null,
1065
1579
  async processBuffer(buffer, procOptions = {}) {
1066
1580
  return semaphore.run(
1067
1581
  () => processImageBuffer(buffer, {
@@ -1069,34 +1583,48 @@ function createImageUploader(options) {
1069
1583
  optimizationTimeoutMs,
1070
1584
  blurTimeoutMs,
1071
1585
  includeBlurHash: defaultIncludeBlurHash,
1586
+ limitInputPixels: defaultLimitInputPixels,
1587
+ effort: defaultEffort,
1072
1588
  ...procOptions
1073
- })
1589
+ }),
1590
+ { signal: procOptions.signal }
1074
1591
  );
1075
1592
  },
1076
1593
  async uploadImage(input, uploadOptions = {}) {
1077
1594
  const presetConfig = resolvePreset(uploadOptions.preset);
1595
+ if (uploadOptions.preset && !presetConfig) {
1596
+ throw new UploadValidationError(`Invalid preset "${uploadOptions.preset}".`);
1597
+ }
1078
1598
  const bucket = uploadOptions.bucket || presetConfig?.bucket || defaultBucket;
1079
1599
  const folder = uploadOptions.folder ?? presetConfig?.folder ?? defaultFolder;
1080
1600
  const maxSizeMB = uploadOptions.maxSizeMB ?? presetConfig?.maxSizeMB ?? defaultMaxSizeMB;
1081
- const allowedFormats = uploadOptions.allowedFormats ?? presetConfig?.allowedFormats ?? defaultAllowedFormats;
1601
+ const allowSvg = uploadOptions.allowSvg ?? presetConfig?.allowSvg ?? defaultAllowSvg;
1602
+ const explicitAllowSvg = Boolean(
1603
+ uploadOptions.allowSvg ?? presetConfig?.allowSvg ?? (defaultAllowSvg && !uploadOptions.allowedFormats && !presetConfig?.allowedFormats)
1604
+ );
1605
+ const allowedFormats = resolveEffectiveAllowedFormats(
1606
+ uploadOptions.allowedFormats ?? presetConfig?.allowedFormats ?? defaultAllowedFormats,
1607
+ allowSvg,
1608
+ explicitAllowSvg
1609
+ );
1082
1610
  const maxDimensions = uploadOptions.maxDimensions ?? presetConfig?.maxDimensions ?? defaultMaxDimensions;
1083
1611
  const outputFormat = uploadOptions.outputFormat ?? presetConfig?.outputFormat;
1084
1612
  const quality = uploadOptions.quality ?? presetConfig?.quality;
1085
1613
  const includeBlurHash = uploadOptions.includeBlurHash ?? presetConfig?.includeBlurHash ?? defaultIncludeBlurHash;
1614
+ const limitInputPixels = uploadOptions.limitInputPixels ?? presetConfig?.limitInputPixels ?? defaultLimitInputPixels;
1615
+ const variantWidths = uploadOptions.variantWidths ?? presetConfig?.variantWidths;
1086
1616
  const isWebFile = typeof input.arrayBuffer === "function";
1087
- const fileType = input.type;
1088
- const originalName = input.name;
1089
- const fileSize = isWebFile ? input.size : input.size ?? input.buffer.length;
1090
- const validation = validateImageFileStrict(
1091
- { size: fileSize, type: fileType },
1092
- maxSizeMB,
1093
- allowedFormats
1094
- );
1095
- if (!validation.valid) {
1096
- throw new UploadValidationError(validation.error);
1617
+ if (isWebFile) {
1618
+ const pre = validateImageFileStrict({ size: input.size, type: input.type }, maxSizeMB, allowedFormats);
1619
+ if (!pre.valid) throw new UploadValidationError(pre.error);
1097
1620
  }
1098
1621
  try {
1099
- const originalBuffer = isWebFile ? Buffer.from(await input.arrayBuffer()) : input.buffer;
1622
+ let originalBuffer = isWebFile ? Buffer.from(await input.arrayBuffer()) : input.buffer;
1623
+ if (!originalBuffer || originalBuffer.length === 0) throw new UploadValidationError("File is empty");
1624
+ const validation = validateImageFileStrict({ size: originalBuffer.length, type: input.type }, maxSizeMB, allowedFormats);
1625
+ if (!validation.valid) throw new UploadValidationError(validation.error);
1626
+ const sniffed = assertMagicBytesAllowed(originalBuffer, allowedFormats, allowSvg);
1627
+ if (sniffed.format === "svg") originalBuffer = sanitizeSvg(originalBuffer);
1100
1628
  const { optimized, blurData, contentHash, originalSize } = await semaphore.run(
1101
1629
  () => processImageBuffer(originalBuffer, {
1102
1630
  maxDimensions,
@@ -1104,12 +1632,15 @@ function createImageUploader(options) {
1104
1632
  quality,
1105
1633
  includeBlurHash,
1106
1634
  optimizationTimeoutMs,
1107
- blurTimeoutMs
1108
- })
1635
+ blurTimeoutMs,
1636
+ limitInputPixels,
1637
+ effort: defaultEffort,
1638
+ allowSvg,
1639
+ signal: uploadOptions.signal
1640
+ }),
1641
+ { signal: uploadOptions.signal }
1109
1642
  );
1110
1643
  const mainFormat = optimized.format;
1111
- const extension = formatToExtension(mainFormat);
1112
- const mainContentType = mainFormat === "svg" ? "image/svg+xml" : `image/${mainFormat}`;
1113
1644
  const { uploadResult, storageTarget } = await uploadWithCollisionRetry({
1114
1645
  storage,
1115
1646
  buildPath,
@@ -1118,23 +1649,52 @@ function createImageUploader(options) {
1118
1649
  folder,
1119
1650
  preset: uploadOptions.preset,
1120
1651
  format: mainFormat,
1121
- extension,
1652
+ extension: formatToExtension(mainFormat),
1122
1653
  customFilename: uploadOptions.customFilename,
1654
+ isClientFilename: uploadOptions.isClientFilename,
1123
1655
  contentHash,
1124
- originalName,
1656
+ originalName: input.name,
1125
1657
  metadata: uploadOptions.metadata
1126
1658
  },
1127
1659
  data: optimized.buffer,
1128
- contentType: mainContentType
1660
+ contentType: mainFormat === "svg" ? SVG_MIME_TYPE : `image/${mainFormat}`
1129
1661
  });
1130
- const resolvedUrl = mirrorBufferLocally({
1662
+ const resolvedUrl = await mirrorBufferLocally({
1131
1663
  buffer: optimized.buffer,
1132
1664
  filename: storageTarget.filename,
1665
+ storagePath: uploadResult.path,
1133
1666
  fallbackUrl: uploadResult.url,
1134
1667
  mirrorDir: presetConfig?.mirrorDir,
1135
1668
  publicUrlBase: presetConfig?.publicUrlBase
1136
1669
  });
1137
- const resolvedQuality = quality ?? 85;
1670
+ const { variants, srcSet } = variantWidths?.length && mainFormat !== "svg" ? await semaphore.run(
1671
+ () => generateImageVariants({
1672
+ sourceBuffer: originalBuffer,
1673
+ format: mainFormat,
1674
+ quality: optimized.quality ?? quality,
1675
+ effort: defaultEffort,
1676
+ limitInputPixels,
1677
+ mainWidth: optimized.width,
1678
+ mainHeight: optimized.height,
1679
+ mainUrl: resolvedUrl,
1680
+ baseStoragePath: uploadResult.path,
1681
+ baseFilename: storageTarget.filename,
1682
+ bucket: storageTarget.bucket,
1683
+ storage,
1684
+ variantWidths,
1685
+ upsert: Boolean(uploadOptions.customFilename) && !uploadOptions.isClientFilename,
1686
+ contentHash,
1687
+ metadata: uploadOptions.metadata,
1688
+ mirrorDir: presetConfig?.mirrorDir,
1689
+ publicUrlBase: presetConfig?.publicUrlBase
1690
+ }),
1691
+ { signal: uploadOptions.signal }
1692
+ ) : { variants: [], srcSet: void 0 };
1693
+ const variantPaths = variants.map((v) => v.storagePath).filter(Boolean);
1694
+ if (variantPaths.length > 0) {
1695
+ trackedVariants.set(`${storageTarget.bucket}:${uploadResult.path}`, variantPaths);
1696
+ variants.forEach((v) => observedWidths.add(v.width));
1697
+ }
1138
1698
  const result = {
1139
1699
  url: resolvedUrl,
1140
1700
  blurDataUrl: blurData.dataURL,
@@ -1152,48 +1712,90 @@ function createImageUploader(options) {
1152
1712
  ...uploadOptions.preset && { preset: uploadOptions.preset },
1153
1713
  ...folder && { folder },
1154
1714
  ...uploadOptions.metadata && { metadata: uploadOptions.metadata },
1715
+ ...variants.length > 0 && { variants },
1716
+ ...srcSet && { srcSet },
1155
1717
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1156
1718
  };
1157
- if (onUploadRecord) {
1158
- const record = {
1159
- ...result,
1160
- quality: resolvedQuality
1161
- };
1162
- if (awaitRecordHook) {
1163
- await onUploadRecord(record);
1164
- } else {
1165
- Promise.resolve(onUploadRecord(record)).catch(() => {
1166
- });
1167
- }
1168
- }
1719
+ const record = { ...result, quality: optimized.quality ?? quality ?? 85 };
1720
+ await invokeRecordHookWithRollback({
1721
+ onUploadRecord,
1722
+ awaitRecordHook,
1723
+ record,
1724
+ storage,
1725
+ bucket: storageTarget.bucket,
1726
+ mainPath: uploadResult.path,
1727
+ variantPaths,
1728
+ mirrorDirs: getMirrorDirs(),
1729
+ onError
1730
+ });
1169
1731
  return result;
1170
1732
  } catch (error) {
1171
- if (error instanceof UploadValidationError) {
1733
+ if (error instanceof UploadValidationError || error instanceof SemaphoreQueueFullError) {
1172
1734
  throw error;
1173
1735
  }
1174
- throw new Error(
1175
- `Image upload failed: ${error instanceof Error ? error.message : "Unknown error"}`
1176
- );
1736
+ throw new Error(`Image upload failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1177
1737
  }
1178
1738
  },
1179
1739
  async deleteImage(targetPath, bucket) {
1740
+ const targetBucket = bucket || defaultBucket;
1180
1741
  try {
1181
- await storage.delete(bucket || defaultBucket, targetPath);
1742
+ await storage.delete(targetBucket, targetPath);
1743
+ await deleteMirroredFiles(getMirrorDirs(), targetPath);
1744
+ await cleanupVariantsFor(targetBucket, targetPath);
1182
1745
  } catch (error) {
1183
- throw new Error(
1184
- `Image deletion failed: ${error instanceof Error ? error.message : "Unknown error"}`
1185
- );
1746
+ throw new Error(`Image deletion failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1186
1747
  }
1187
1748
  },
1188
1749
  async deleteByUrl(url) {
1189
- if (storage.deleteByUrl) {
1190
- return storage.deleteByUrl(url);
1750
+ const parsed = storage.parseUrl?.(url);
1751
+ const deleted = storage.deleteByUrl ? await storage.deleteByUrl(url) : false;
1752
+ if (deleted && parsed?.path) {
1753
+ await deleteMirroredFiles(getMirrorDirs(), parsed.path);
1754
+ await cleanupVariantsFor(parsed.bucket || defaultBucket, parsed.path);
1191
1755
  }
1192
- return false;
1756
+ return deleted;
1193
1757
  }
1194
1758
  };
1195
1759
  }
1196
-
1197
- export { AsyncSemaphore, BLUR_PLACEHOLDER_FALLBACK_WIDTH, BLUR_PLACEHOLDER_MAX_CHARS, BLUR_PLACEHOLDER_QUALITY, BLUR_PLACEHOLDER_WIDTH, EDGE_COLOR_FALLBACK, EDGE_WHITE_FALLBACK, PRIMARY_AVIF_EFFORT, RETRY_AVIF_EFFORT, UploadValidationError, WHITE_DOMINANCE_RATIO, blendEdgeColors, createDefaultPathGenerator, createImageUploader, createUploadHttpHandler, encodeBlurHashString, extractEdgeColorsFromBuffer, extractEnhancedColorFromBuffer, formatToExtension, generateBlurHash, generateBlurPlaceholder, isClientValidationError, isCollisionError, mirrorBufferLocally, optimizeImage, processImageBuffer, sanitizeSegment, uploadWithCollisionRetry, withTimeout };
1198
- //# sourceMappingURL=server.js.map
1760
+ export {
1761
+ AsyncSemaphore,
1762
+ BLUR_PLACEHOLDER_FALLBACK_WIDTH,
1763
+ BLUR_PLACEHOLDER_MAX_CHARS,
1764
+ BLUR_PLACEHOLDER_QUALITY,
1765
+ BLUR_PLACEHOLDER_WIDTH,
1766
+ DEFAULT_LIMIT_INPUT_PIXELS,
1767
+ DEFAULT_MAX_SVG_BYTES,
1768
+ EDGE_COLOR_FALLBACK,
1769
+ EDGE_WHITE_FALLBACK,
1770
+ PRIMARY_AVIF_EFFORT,
1771
+ RETRY_AVIF_EFFORT,
1772
+ SemaphoreQueueFullError,
1773
+ UploadValidationError,
1774
+ WHITE_DOMINANCE_RATIO,
1775
+ assertMagicBytesAllowed,
1776
+ blendEdgeColors,
1777
+ buildVariantStoragePath,
1778
+ computeEdgeColorSet,
1779
+ createDefaultPathGenerator,
1780
+ createImageUploader,
1781
+ createUploadHandler,
1782
+ createUploadHttpHandler,
1783
+ encodeBlurHashString,
1784
+ extractEdgeColorsFromBuffer,
1785
+ extractEnhancedColorFromBuffer,
1786
+ formatToExtension,
1787
+ generateBlurHash,
1788
+ generateBlurPlaceholder,
1789
+ generateImageVariants,
1790
+ isClientValidationError,
1791
+ isCollisionError,
1792
+ mirrorBufferLocally,
1793
+ optimizeImage,
1794
+ processImageBuffer,
1795
+ sanitizeSegment,
1796
+ sanitizeSvg,
1797
+ sniffImageFormat,
1798
+ uploadWithCollisionRetry,
1799
+ withTimeout
1800
+ };
1199
1801
  //# sourceMappingURL=server.js.map