img-upload-kit 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -41
- package/MIGRATION.md +192 -0
- package/README.md +138 -154
- package/dist/bin.js +1186 -0
- package/dist/bin.js.map +1 -0
- package/dist/blur.cjs +40 -412
- package/dist/blur.cjs.map +1 -1
- package/dist/blur.d.cts +5 -5
- package/dist/blur.d.ts +5 -5
- package/dist/blur.js +39 -393
- package/dist/blur.js.map +1 -1
- package/dist/blurDataUtils-DveBRtqE.d.cts +43 -0
- package/dist/blurDataUtils-yx9Q6HlU.d.ts +43 -0
- package/dist/blurService-D5TpMNji.d.ts +83 -0
- package/dist/blurService-DacwylJx.d.cts +83 -0
- package/dist/chunk-2OVNXHTN.cjs +514 -0
- package/dist/chunk-2OVNXHTN.cjs.map +1 -0
- package/dist/chunk-3ICG2SLL.cjs +135 -0
- package/dist/chunk-3ICG2SLL.cjs.map +1 -0
- package/dist/chunk-4ASFG3KB.js +514 -0
- package/dist/chunk-4ASFG3KB.js.map +1 -0
- package/dist/chunk-AI3JJLSA.js +2370 -0
- package/dist/chunk-AI3JJLSA.js.map +1 -0
- package/dist/chunk-CV23O4YU.js +145 -0
- package/dist/chunk-CV23O4YU.js.map +1 -0
- package/dist/chunk-G7LLSKBV.js +158 -0
- package/dist/chunk-G7LLSKBV.js.map +1 -0
- package/dist/chunk-IB3TEDLR.js +294 -0
- package/dist/chunk-IB3TEDLR.js.map +1 -0
- package/dist/chunk-KJVYWKIG.cjs +158 -0
- package/dist/chunk-KJVYWKIG.cjs.map +1 -0
- package/dist/chunk-NEDBIIE3.cjs +226 -0
- package/dist/chunk-NEDBIIE3.cjs.map +1 -0
- package/dist/chunk-QHU7FUO4.cjs +37 -0
- package/dist/chunk-QHU7FUO4.cjs.map +1 -0
- package/dist/chunk-QQHZJKNA.js +135 -0
- package/dist/chunk-QQHZJKNA.js.map +1 -0
- package/dist/chunk-S2IFAWJK.js +37 -0
- package/dist/chunk-S2IFAWJK.js.map +1 -0
- package/dist/chunk-TT5C7K3Q.js +226 -0
- package/dist/chunk-TT5C7K3Q.js.map +1 -0
- package/dist/chunk-U2TWTEBY.cjs +2370 -0
- package/dist/chunk-U2TWTEBY.cjs.map +1 -0
- package/dist/chunk-WISG74VL.cjs +294 -0
- package/dist/chunk-WISG74VL.cjs.map +1 -0
- package/dist/chunk-YRXXQUDE.cjs +145 -0
- package/dist/chunk-YRXXQUDE.cjs.map +1 -0
- package/dist/cli.cjs +273 -76
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +269 -73
- package/dist/cli.js.map +1 -1
- package/dist/headless.cjs +8 -1880
- package/dist/headless.cjs.map +1 -1
- package/dist/headless.d.cts +3 -1
- package/dist/headless.d.ts +3 -1
- package/dist/headless.js +25 -1886
- package/dist/headless.js.map +1 -1
- package/dist/index-BIdnqtW8.d.ts +234 -0
- package/dist/index-Brk_uHs6.d.cts +234 -0
- package/dist/{index-FVXXKf2v.d.ts → index-CPMlaQsC.d.ts} +76 -153
- package/dist/{index-FVXXKf2v.d.cts → index-DHINe-rA.d.cts} +76 -153
- package/dist/index.cjs +64 -532
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +63 -504
- package/dist/index.js.map +1 -1
- package/dist/{memoryAdapter-CBu7_3Pp.d.ts → memoryAdapter-Ck0ccZbF.d.cts} +3 -2
- package/dist/{memoryAdapter-BJjyLR-1.d.cts → memoryAdapter-DxEKaG0b.d.ts} +3 -2
- package/dist/placeholders-C4ftwaII.d.ts +5 -0
- package/dist/placeholders-x_3rt65M.d.cts +5 -0
- package/dist/react.cjs +1952 -3257
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +188 -50
- package/dist/react.d.ts +188 -50
- package/dist/react.js +1763 -3013
- package/dist/react.js.map +1 -1
- package/dist/server-handler.cjs +14 -0
- package/dist/server-handler.cjs.map +1 -0
- package/dist/server-handler.d.cts +3 -0
- package/dist/server-handler.d.ts +3 -0
- package/dist/server-handler.js +14 -0
- package/dist/server-handler.js.map +1 -0
- package/dist/server.cjs +1096 -532
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +77 -154
- package/dist/server.d.ts +77 -154
- package/dist/server.js +1050 -448
- package/dist/server.js.map +1 -1
- package/dist/storage.cjs +239 -232
- package/dist/storage.cjs.map +1 -1
- package/dist/storage.d.cts +21 -5
- package/dist/storage.d.ts +21 -5
- package/dist/storage.js +227 -207
- package/dist/storage.js.map +1 -1
- package/dist/styles/animated-image.css +112 -0
- package/dist/styles/dropzone-and-tiles.css +247 -0
- package/dist/styles/modals-and-dock.css +248 -0
- package/dist/styles/primitives.css +197 -0
- package/dist/styles/quality-and-inputs.css +184 -0
- package/dist/styles/tokens.css +123 -0
- package/dist/styles.css +39 -24
- package/dist/types-BrP-o9nd.d.cts +85 -0
- package/dist/types-sZ3KoFQY.d.ts +85 -0
- package/dist/types-yShGNQnW.d.cts +61 -0
- package/dist/types-yShGNQnW.d.ts +61 -0
- package/dist/{types-CiLY0DwB.d.cts → upload-C5Trd6Dh.d.cts} +27 -53
- package/dist/{types-CiLY0DwB.d.ts → upload-C5Trd6Dh.d.ts} +27 -53
- package/package.json +92 -32
- package/dist/blurService-DqsnfPTd.d.cts +0 -172
- package/dist/blurService-DqsnfPTd.d.ts +0 -172
package/dist/server.js
CHANGED
|
@@ -1,323 +1,274 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import {
|
|
2
|
+
UploadValidationError,
|
|
3
|
+
createUploadHandler,
|
|
4
|
+
createUploadHttpHandler,
|
|
5
|
+
isClientValidationError
|
|
6
|
+
} from "./chunk-4ASFG3KB.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-TT5C7K3Q.js";
|
|
7
17
|
|
|
8
|
-
// src/server/
|
|
9
|
-
var
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
var
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
return VALIDATION_ERROR_SUBSTRINGS.some((sub) => msg.includes(sub));
|
|
81
|
+
return false;
|
|
43
82
|
}
|
|
44
|
-
function
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
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, """);
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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, """);
|
|
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, """)}"`);
|
|
126
|
+
} else {
|
|
127
|
+
kept.push(rawName);
|
|
154
128
|
}
|
|
155
129
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
187
|
+
return Buffer.from(svg.trim(), "utf8");
|
|
186
188
|
}
|
|
187
189
|
|
|
188
|
-
// src/server/optimization/
|
|
189
|
-
var
|
|
190
|
-
var
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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/
|
|
200
|
-
var
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
limitInputPixels
|
|
247
|
+
failOn: "error",
|
|
248
|
+
limitInputPixels
|
|
299
249
|
}).rotate();
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
318
|
-
height:
|
|
266
|
+
buffer: sanitizedBuffer,
|
|
267
|
+
width: dims.width,
|
|
268
|
+
height: dims.height,
|
|
319
269
|
format: "svg",
|
|
320
|
-
size:
|
|
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:
|
|
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:
|
|
362
|
-
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:
|
|
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:
|
|
382
|
-
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
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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:
|
|
394
|
-
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:
|
|
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 ? {
|
|
402
|
-
|
|
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:
|
|
406
|
-
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/
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
return encode(
|
|
723
|
-
new Uint8ClampedArray(data),
|
|
704
|
+
return encodeRawBlurHash(
|
|
705
|
+
data,
|
|
724
706
|
info.width,
|
|
725
707
|
info.height,
|
|
726
|
-
|
|
727
|
-
|
|
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 =
|
|
723
|
+
processBuffer = Buffer.from(normalizedSvg, "utf8");
|
|
741
724
|
}
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
}
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
883
|
-
function
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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(/\/+$/, "")}/${
|
|
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
|
|
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
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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(
|
|
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
|
-
|
|
1190
|
-
|
|
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
|
|
1756
|
+
return deleted;
|
|
1193
1757
|
}
|
|
1194
1758
|
};
|
|
1195
1759
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|