ipx 4.0.0-alpha.1 → 4.0.0-beta.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.
@@ -1,19 +1,90 @@
1
- import { a as __toESM, i as __toDynamicImportESM, n as __export } from "./rolldown-runtime.mjs";
2
- import { i as withLeadingSlash, n as hasProtocol, r as joinURL, t as decode } from "./libs/ufo.mjs";
3
- import { n as defineEventHandler, t as HTTPError } from "./libs/h3.mjs";
4
- import { t as imageMeta } from "./libs/image-meta.mjs";
5
- import { t as require_etag } from "./libs/etag.mjs";
6
- import { t as require_accept_negotiator } from "./libs/@fastify/accept-negotiator.mjs";
7
- import { join, parse, resolve } from "pathe";
8
-
9
- //#region src/handlers/utils.ts
1
+ import { __exportAll, __toESM } from "./rolldown-runtime.mjs";
2
+ import { hasProtocol, joinURL, withLeadingSlash } from "./libs/ufo.mjs";
3
+ import { HTTPError, defineEventHandler } from "./libs/h3.mjs";
4
+ import { imageMeta } from "./libs/image-meta.mjs";
5
+ import { require_etag } from "./libs/etag.mjs";
6
+ import { require_accept_negotiator } from "./libs/@fastify/accept-negotiator.mjs";
10
7
  function VArg(argument) {
11
8
  if (argument === "Infinity") return Infinity;
9
+ if (argument === "undefined") return;
12
10
  try {
13
11
  const val = JSON.parse(argument);
14
12
  const t = typeof val;
15
13
  if (t === "boolean" || t === "number" || t === "string" || val === null) return val;
16
14
  } catch {}
15
+ return argument;
16
+ }
17
+ function invalidArg(name, argument, expected) {
18
+ throw new HTTPError({
19
+ statusCode: 400,
20
+ statusText: "IPX_INVALID_MODIFIER_ARG",
21
+ message: `Invalid \`${name}\` modifier argument: \`${argument}\` (expected ${expected})`
22
+ });
23
+ }
24
+ function isOmitted(argument) {
25
+ return argument === void 0 || argument === "" || argument === "undefined";
26
+ }
27
+ function VNumber(name, options = {}) {
28
+ const { min, max, integer } = options;
29
+ const expected = [integer ? "an integer" : "a number", min !== void 0 && max !== void 0 ? `between ${min} and ${max}` : min !== void 0 && `greater than or equal to ${min}` || max !== void 0 && `less than or equal to ${max}` || ""].filter(Boolean).join(" ");
30
+ return (argument) => {
31
+ if (isOmitted(argument)) return;
32
+ const value = VArg(argument);
33
+ if (typeof value === "number" && Number.isFinite(value) && (!integer || Number.isInteger(value)) && (min === void 0 || value >= min) && (max === void 0 || value <= max)) return value;
34
+ invalidArg(name, argument, expected);
35
+ };
36
+ }
37
+ function VEnum(name, values) {
38
+ const expected = `one of: ${values.join(", ")}`;
39
+ return (argument) => {
40
+ if (isOmitted(argument)) return;
41
+ if (values.includes(argument)) return argument;
42
+ invalidArg(name, argument, expected);
43
+ };
44
+ }
45
+ function VBoolean(name) {
46
+ return (argument) => {
47
+ if (isOmitted(argument)) return;
48
+ if (argument === "true" || argument === "1") return true;
49
+ if (argument === "false" || argument === "0") return false;
50
+ invalidArg(name, argument, "`true` or `false`");
51
+ };
52
+ }
53
+ const HEX_COLOR_RE = /^#?(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i;
54
+ const FN_COLOR_RE = /^(?:rgba?|hsla?|hwb)\([\d\s%,./-]+\)$/i;
55
+ const NAMED_COLOR_RE = /^[a-z]{3,20}$/i;
56
+ function VColor(name) {
57
+ return (argument) => {
58
+ if (isOmitted(argument)) return;
59
+ if (HEX_COLOR_RE.test(argument)) return argument.startsWith("#") ? argument : `#${argument}`;
60
+ if (FN_COLOR_RE.test(argument) || NAMED_COLOR_RE.test(argument)) return argument;
61
+ invalidArg(name, argument, "a hex (`f00`, `ff0000`) or named (`red`) colour");
62
+ };
63
+ }
64
+ const SIZE_RE = /^(\d+)(?:x(\d+))?$/;
65
+ function VSize(name) {
66
+ return (argument) => {
67
+ if (isOmitted(argument)) return;
68
+ const match = SIZE_RE.exec(argument);
69
+ const width = Number(match?.[1]);
70
+ const height = match?.[2] === void 0 ? width : Number(match[2]);
71
+ if (match && width > 0 && height > 0) return {
72
+ width,
73
+ height
74
+ };
75
+ invalidArg(name, argument, "`{width}x{height}` of positive integers");
76
+ };
77
+ }
78
+ function VRequired(name, mapper) {
79
+ return (argument) => {
80
+ const value = mapper(argument);
81
+ if (value === void 0) throw new HTTPError({
82
+ statusCode: 400,
83
+ statusText: "IPX_MISSING_MODIFIER_ARG",
84
+ message: `Missing \`${name}\` modifier argument`
85
+ });
86
+ return value;
87
+ };
17
88
  }
18
89
  function parseArgs(arguments_, mappers) {
19
90
  const vargs = arguments_.split("_");
@@ -24,33 +95,125 @@ function getHandler(key) {
24
95
  }
25
96
  function applyHandler(context, pipe, handler, argumentsString) {
26
97
  const arguments_ = handler.args ? parseArgs(argumentsString, handler.args) : [];
27
- return handler.apply(context, pipe, ...arguments_);
98
+ try {
99
+ return handler.apply(context, pipe, ...arguments_);
100
+ } catch (error) {
101
+ throw asModifierError(error);
102
+ }
103
+ }
104
+ function asModifierError(error) {
105
+ if (HTTPError.isError(error)) return error;
106
+ return new HTTPError({
107
+ statusCode: 400,
108
+ statusText: "IPX_INVALID_MODIFIER",
109
+ message: `Cannot apply modifier: ${error?.message || error}`,
110
+ cause: error
111
+ });
112
+ }
113
+ const SVG_NS = "http://www.w3.org/2000/svg";
114
+ function opacityOverlay(opacity, background) {
115
+ return background ? {
116
+ input: Buffer.from(`<svg xmlns="${SVG_NS}" width="1" height="1"><rect width="1" height="1" fill="${background}" fill-opacity="${1 - opacity}"/></svg>`),
117
+ tile: true,
118
+ blend: "over"
119
+ } : {
120
+ input: { create: {
121
+ width: 1,
122
+ height: 1,
123
+ channels: 4,
124
+ background: {
125
+ r: 0,
126
+ g: 0,
127
+ b: 0,
128
+ alpha: opacity
129
+ }
130
+ } },
131
+ tile: true,
132
+ blend: "dest-in"
133
+ };
134
+ }
135
+ function clampToMaxDimension(max, desired, source) {
136
+ const { width, height } = desired;
137
+ if (!max || width === void 0 && height === void 0) return desired;
138
+ const ratio = source?.width && source?.height ? source.width / source.height : void 0;
139
+ const effectiveWidth = width ?? (ratio && height ? height * ratio : void 0);
140
+ const effectiveHeight = height ?? (ratio && width ? width / ratio : void 0);
141
+ const scale = Math.min(effectiveWidth ? max / effectiveWidth : 1, effectiveHeight ? max / effectiveHeight : 1);
142
+ if (scale >= 1) return desired;
143
+ const clamp = (value) => value === void 0 ? void 0 : Math.max(1, Math.min(max, Math.round(value * scale)));
144
+ return {
145
+ width: clamp(width),
146
+ height: clamp(height)
147
+ };
148
+ }
149
+ function resizeOutputDimensions(desired, source, options = {}) {
150
+ const { width, height } = desired;
151
+ if (width === void 0 && height === void 0) return;
152
+ const sourceWidth = source?.width;
153
+ const sourceHeight = source?.height;
154
+ if (!sourceWidth || !sourceHeight) return desired;
155
+ const derive = (value) => Math.max(1, Math.round(value));
156
+ let out = {
157
+ width: width ?? derive(height * sourceWidth / sourceHeight),
158
+ height: height ?? derive(width * sourceHeight / sourceWidth)
159
+ };
160
+ if (options.fit === "outside" && width !== void 0 && height !== void 0) {
161
+ const scale = Math.max(width / sourceWidth, height / sourceHeight);
162
+ out = {
163
+ width: derive(sourceWidth * scale),
164
+ height: derive(sourceHeight * scale)
165
+ };
166
+ }
167
+ return options.withoutEnlargement ? {
168
+ width: Math.min(out.width, sourceWidth),
169
+ height: Math.min(out.height, sourceHeight)
170
+ } : out;
171
+ }
172
+ function clampExtendEdges(max, edges, source) {
173
+ if (!max) return edges;
174
+ const [top, bottom] = clampEdges(max - (source?.height || 0), edges.top, edges.bottom);
175
+ const [left, right] = clampEdges(max - (source?.width || 0), edges.left, edges.right);
176
+ return {
177
+ top,
178
+ right,
179
+ bottom,
180
+ left
181
+ };
182
+ }
183
+ function clampEdges(available, a, b) {
184
+ const total = (a || 0) + (b || 0);
185
+ if (total === 0 || total <= available) return [a, b];
186
+ const scale = Math.max(0, available) / total;
187
+ const clamp = (value) => value === void 0 ? void 0 : Math.floor(value * scale);
188
+ return [clamp(a), clamp(b)];
28
189
  }
29
190
  function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
30
191
  const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
31
- let { width: width$1, height: height$1 } = desiredDimensions;
32
- if (sourceDimensions.width && width$1 > sourceDimensions.width) {
33
- width$1 = sourceDimensions.width;
34
- height$1 = Math.round(sourceDimensions.width / desiredAspectRatio);
192
+ let { width, height } = desiredDimensions;
193
+ if (sourceDimensions.width && width > sourceDimensions.width) {
194
+ width = sourceDimensions.width;
195
+ height = Math.round(sourceDimensions.width / desiredAspectRatio);
35
196
  }
36
- if (sourceDimensions.height && height$1 > sourceDimensions.height) {
37
- height$1 = sourceDimensions.height;
38
- width$1 = Math.round(sourceDimensions.height * desiredAspectRatio);
197
+ if (sourceDimensions.height && height > sourceDimensions.height) {
198
+ height = sourceDimensions.height;
199
+ width = Math.round(sourceDimensions.height * desiredAspectRatio);
39
200
  }
40
201
  return {
41
- width: width$1,
42
- height: height$1
202
+ width,
203
+ height
43
204
  };
44
205
  }
45
-
46
- //#endregion
47
- //#region src/handlers/handlers.ts
48
- var handlers_exports = /* @__PURE__ */ __export({
206
+ var handlers_exports = /* @__PURE__ */ __exportAll({
207
+ autoorient: () => autoorient,
49
208
  b: () => b,
50
209
  background: () => background,
51
210
  blur: () => blur,
211
+ brightness: () => brightness,
212
+ clahe: () => clahe,
52
213
  crop: () => crop,
214
+ dilate: () => dilate,
53
215
  enlarge: () => enlarge,
216
+ erode: () => erode,
54
217
  extend: () => extend,
55
218
  extract: () => extract,
56
219
  fit: () => fit,
@@ -61,11 +224,15 @@ var handlers_exports = /* @__PURE__ */ __export({
61
224
  grayscale: () => grayscale,
62
225
  h: () => h,
63
226
  height: () => height,
227
+ hue: () => hue,
64
228
  kernel: () => kernel,
229
+ lightness: () => lightness,
230
+ linear: () => linear,
65
231
  median: () => median,
66
232
  modulate: () => modulate,
67
233
  negate: () => negate,
68
234
  normalize: () => normalize,
235
+ opacity: () => opacity,
69
236
  pos: () => pos,
70
237
  position: () => position,
71
238
  q: () => q,
@@ -73,43 +240,93 @@ var handlers_exports = /* @__PURE__ */ __export({
73
240
  resize: () => resize,
74
241
  rotate: () => rotate,
75
242
  s: () => s,
243
+ saturation: () => saturation,
76
244
  sharpen: () => sharpen,
77
245
  threshold: () => threshold,
78
246
  tint: () => tint,
79
247
  trim: () => trim,
248
+ unflatten: () => unflatten,
80
249
  w: () => w,
81
250
  width: () => width
82
251
  });
83
252
  const quality = {
84
- args: [VArg],
253
+ args: [VNumber("quality", {
254
+ min: 1,
255
+ max: 100,
256
+ integer: true
257
+ })],
85
258
  order: -1,
86
- apply: (context, _pipe, quality$1) => {
87
- context.quality = quality$1;
259
+ apply: (context, _pipe, quality) => {
260
+ context.quality = quality;
88
261
  }
89
262
  };
90
263
  const fit = {
91
- args: [VArg],
264
+ args: [VEnum("fit", [
265
+ "contain",
266
+ "cover",
267
+ "fill",
268
+ "inside",
269
+ "outside"
270
+ ])],
92
271
  order: -1,
93
- apply: (context, _pipe, fit$1) => {
94
- context.fit = fit$1;
272
+ apply: (context, _pipe, fit) => {
273
+ context.fit = fit;
95
274
  }
96
275
  };
276
+ const POSITIONS = [
277
+ "top",
278
+ "right top",
279
+ "right",
280
+ "right bottom",
281
+ "bottom",
282
+ "left bottom",
283
+ "left",
284
+ "left top",
285
+ "north",
286
+ "northeast",
287
+ "east",
288
+ "southeast",
289
+ "south",
290
+ "southwest",
291
+ "west",
292
+ "northwest",
293
+ "center",
294
+ "centre",
295
+ "entropy",
296
+ "attention"
297
+ ];
298
+ const GRAVITIES = /* @__PURE__ */ new Set([
299
+ 0,
300
+ 1,
301
+ 2,
302
+ 3,
303
+ 4,
304
+ 5,
305
+ 6,
306
+ 7,
307
+ 8,
308
+ 16,
309
+ 17
310
+ ]);
311
+ const VPosition = (name) => {
312
+ const vEnum = VEnum(name, POSITIONS);
313
+ return (argument) => {
314
+ if (/^\d+$/.test(argument) && GRAVITIES.has(Number(argument))) return Number(argument);
315
+ return vEnum(argument?.replace("-", " "));
316
+ };
317
+ };
97
318
  const position = {
98
- args: [VArg],
319
+ args: [VPosition("position")],
99
320
  order: -1,
100
- apply: (context, _pipe, position$1) => {
101
- context.position = position$1;
321
+ apply: (context, _pipe, position) => {
322
+ context.position = position;
102
323
  }
103
324
  };
104
- const HEX_RE = /^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
105
- const SHORTHEX_RE = /^([\da-f])([\da-f])([\da-f])$/i;
106
325
  const background = {
107
- args: [VArg],
326
+ args: [VColor("background")],
108
327
  order: -1,
109
- apply: (context, _pipe, background$1) => {
110
- background$1 = String(background$1);
111
- if (!background$1.startsWith("#") && (HEX_RE.test(background$1) || SHORTHEX_RE.test(background$1))) background$1 = "#" + background$1;
112
- context.background = background$1;
328
+ apply: (context, _pipe, background) => {
329
+ context.background = background;
113
330
  }
114
331
  };
115
332
  const enlarge = {
@@ -119,42 +336,63 @@ const enlarge = {
119
336
  }
120
337
  };
121
338
  const kernel = {
122
- args: [VArg],
123
- apply: (context, _pipe, kernel$1) => {
124
- context.kernel = kernel$1;
339
+ args: [VEnum("kernel", [
340
+ "nearest",
341
+ "linear",
342
+ "cubic",
343
+ "mitchell",
344
+ "lanczos2",
345
+ "lanczos3",
346
+ "mks2013",
347
+ "mks2021"
348
+ ])],
349
+ apply: (context, _pipe, kernel) => {
350
+ context.kernel = kernel;
125
351
  }
126
352
  };
127
353
  const width = {
128
- args: [VArg],
129
- apply: (context, pipe, width$1) => {
130
- return pipe.resize(width$1, void 0, { withoutEnlargement: !context.enlarge });
354
+ args: [VNumber("width", {
355
+ min: 1,
356
+ integer: true
357
+ })],
358
+ apply: (context, pipe, width) => {
359
+ const clamped = clampToMaxDimension(context.maxOutputDimension, { width }, context.meta);
360
+ const withoutEnlargement = !context.enlarge;
361
+ context.outputDimensions = resizeOutputDimensions(clamped, context.meta, { withoutEnlargement });
362
+ return pipe.resize(clamped.width, void 0, { withoutEnlargement });
131
363
  }
132
364
  };
133
365
  const height = {
134
- args: [VArg],
135
- apply: (context, pipe, height$1) => {
136
- return pipe.resize(void 0, height$1, { withoutEnlargement: !context.enlarge });
366
+ args: [VNumber("height", {
367
+ min: 1,
368
+ integer: true
369
+ })],
370
+ apply: (context, pipe, height) => {
371
+ const clamped = clampToMaxDimension(context.maxOutputDimension, { height }, context.meta);
372
+ const withoutEnlargement = !context.enlarge;
373
+ context.outputDimensions = resizeOutputDimensions(clamped, context.meta, { withoutEnlargement });
374
+ return pipe.resize(void 0, clamped.height, { withoutEnlargement });
137
375
  }
138
376
  };
139
377
  const resize = {
140
- args: [
141
- VArg,
142
- VArg,
143
- VArg
144
- ],
378
+ args: [VSize("resize")],
145
379
  apply: (context, pipe, size) => {
146
- let [width$1, height$1] = String(size).split("x").map(Number);
147
- if (!width$1) return;
148
- if (!height$1) height$1 = width$1;
380
+ if (!size) return;
381
+ let { width, height } = size;
149
382
  if (!context.enlarge) {
150
383
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, {
151
- width: width$1,
152
- height: height$1
384
+ width,
385
+ height
153
386
  });
154
- width$1 = clamped.width;
155
- height$1 = clamped.height;
387
+ width = clamped.width;
388
+ height = clamped.height;
156
389
  }
157
- return pipe.resize(width$1, height$1, {
390
+ const capped = clampToMaxDimension(context.maxOutputDimension, {
391
+ width,
392
+ height
393
+ });
394
+ context.outputDimensions = resizeOutputDimensions(capped, context.meta, { fit: context.fit });
395
+ return pipe.resize(capped.width, capped.height, {
158
396
  fit: context.fit,
159
397
  position: context.position,
160
398
  background: context.background,
@@ -163,48 +401,94 @@ const resize = {
163
401
  }
164
402
  };
165
403
  const trim = {
166
- args: [VArg],
167
- apply: (_context, pipe, threshold$1) => {
168
- return pipe.trim(threshold$1);
404
+ args: [VNumber("trim", { min: 0 })],
405
+ apply: (_context, pipe, threshold) => {
406
+ return pipe.trim({ threshold });
169
407
  }
170
408
  };
409
+ const EXTEND_WITH = [
410
+ "background",
411
+ "copy",
412
+ "repeat",
413
+ "mirror"
414
+ ];
415
+ const VExtendEdge = (name) => VNumber(name, {
416
+ min: 0,
417
+ max: 1e4,
418
+ integer: true
419
+ });
171
420
  const extend = {
172
421
  args: [
173
- VArg,
174
- VArg,
175
- VArg,
176
- VArg
422
+ VExtendEdge("extend.top"),
423
+ VExtendEdge("extend.right"),
424
+ VExtendEdge("extend.bottom"),
425
+ VExtendEdge("extend.left"),
426
+ VEnum("extend.extendWith", EXTEND_WITH)
177
427
  ],
178
- apply: (context, pipe, top, right, bottom, left) => {
179
- return pipe.extend({
428
+ order: 1,
429
+ apply: (context, pipe, top, right, bottom, left, extendWith) => {
430
+ const edges = clampExtendEdges(context.maxOutputDimension, {
180
431
  top,
181
- left,
182
- bottom,
183
432
  right,
184
- background: context.background
433
+ bottom,
434
+ left
435
+ }, context.outputDimensions ?? context.meta);
436
+ return pipe.extend({
437
+ top: edges.top,
438
+ left: edges.left,
439
+ bottom: edges.bottom,
440
+ right: edges.right,
441
+ background: context.background,
442
+ extendWith
185
443
  });
186
444
  }
187
445
  };
446
+ const MAX_EXTRACT = 1e8;
188
447
  const extract = {
189
448
  args: [
190
- VArg,
191
- VArg,
192
- VArg,
193
- VArg
449
+ VRequired("extract.left", VNumber("extract.left", {
450
+ min: 0,
451
+ max: MAX_EXTRACT,
452
+ integer: true
453
+ })),
454
+ VRequired("extract.top", VNumber("extract.top", {
455
+ min: 0,
456
+ max: MAX_EXTRACT,
457
+ integer: true
458
+ })),
459
+ VRequired("extract.width", VNumber("extract.width", {
460
+ min: 1,
461
+ max: MAX_EXTRACT,
462
+ integer: true
463
+ })),
464
+ VRequired("extract.height", VNumber("extract.height", {
465
+ min: 1,
466
+ max: MAX_EXTRACT,
467
+ integer: true
468
+ }))
194
469
  ],
195
- apply: (_context, pipe, left, top, width$1, height$1) => {
470
+ apply: (_context, pipe, left, top, width, height) => {
196
471
  return pipe.extract({
197
472
  left,
198
473
  top,
199
- width: width$1,
200
- height: height$1
474
+ width,
475
+ height
201
476
  });
202
477
  }
203
478
  };
204
479
  const rotate = {
205
- args: [VArg],
206
- apply: (context, pipe, angel) => {
207
- return pipe.rotate(angel, { background: context.background });
480
+ args: [VNumber("rotate", {
481
+ min: -3600,
482
+ max: 3600
483
+ })],
484
+ apply: (context, pipe, angle) => {
485
+ return pipe.rotate(angle, { background: context.background });
486
+ }
487
+ };
488
+ const autoorient = {
489
+ args: [],
490
+ apply: (_context, pipe) => {
491
+ return pipe.autoOrient();
208
492
  }
209
493
  };
210
494
  const flip = {
@@ -221,98 +505,243 @@ const flop = {
221
505
  };
222
506
  const sharpen = {
223
507
  args: [
224
- VArg,
225
- VArg,
226
- VArg
508
+ VNumber("sharpen.sigma", {
509
+ min: 1e-6,
510
+ max: 10
511
+ }),
512
+ VNumber("sharpen.flat", {
513
+ min: 0,
514
+ max: 1e6
515
+ }),
516
+ VNumber("sharpen.jagged", {
517
+ min: 0,
518
+ max: 1e6
519
+ }),
520
+ VNumber("sharpen.x1", {
521
+ min: 0,
522
+ max: 1e6
523
+ }),
524
+ VNumber("sharpen.y2", {
525
+ min: 0,
526
+ max: 1e6
527
+ }),
528
+ VNumber("sharpen.y3", {
529
+ min: 0,
530
+ max: 1e6
531
+ })
227
532
  ],
228
- apply: (_context, pipe, sigma, flat, jagged) => {
229
- return pipe.sharpen(sigma, flat, jagged);
533
+ apply: (_context, pipe, sigma, flat, jagged, x1, y2, y3) => {
534
+ return sigma === void 0 ? pipe.sharpen() : pipe.sharpen({
535
+ sigma,
536
+ m1: flat,
537
+ m2: jagged,
538
+ x1,
539
+ y2,
540
+ y3
541
+ });
230
542
  }
231
543
  };
232
544
  const median = {
233
- args: [
234
- VArg,
235
- VArg,
236
- VArg
237
- ],
545
+ args: [VNumber("median", {
546
+ min: 1,
547
+ max: 1e3,
548
+ integer: true
549
+ })],
238
550
  apply: (_context, pipe, size) => {
239
551
  return pipe.median(size);
240
552
  }
241
553
  };
242
554
  const blur = {
243
555
  args: [
244
- VArg,
245
- VArg,
246
- VArg
556
+ VNumber("blur.sigma", {
557
+ min: .3,
558
+ max: 1e3
559
+ }),
560
+ VEnum("blur.precision", [
561
+ "integer",
562
+ "float",
563
+ "approximate"
564
+ ]),
565
+ VNumber("blur.minAmplitude", {
566
+ min: .001,
567
+ max: 1
568
+ })
247
569
  ],
248
- apply: (_context, pipe, sigma) => {
249
- return pipe.blur(sigma);
570
+ apply: (_context, pipe, sigma, precision, minAmplitude) => {
571
+ return sigma === void 0 ? pipe.blur() : pipe.blur({
572
+ sigma,
573
+ ...precision === void 0 ? {} : { precision },
574
+ ...minAmplitude === void 0 ? {} : { minAmplitude }
575
+ });
250
576
  }
251
577
  };
252
- const flatten = {
578
+ const MAX_MORPHOLOGY = 100;
579
+ const dilate = {
580
+ args: [VNumber("dilate", {
581
+ min: 1,
582
+ max: MAX_MORPHOLOGY,
583
+ integer: true
584
+ })],
585
+ apply: (_context, pipe, width) => {
586
+ return pipe.dilate(width);
587
+ }
588
+ };
589
+ const erode = {
590
+ args: [VNumber("erode", {
591
+ min: 1,
592
+ max: MAX_MORPHOLOGY,
593
+ integer: true
594
+ })],
595
+ apply: (_context, pipe, width) => {
596
+ return pipe.erode(width);
597
+ }
598
+ };
599
+ const MAX_CLAHE = 100;
600
+ const clahe = {
253
601
  args: [
254
- VArg,
255
- VArg,
256
- VArg
602
+ VRequired("clahe.width", VNumber("clahe.width", {
603
+ min: 1,
604
+ max: MAX_CLAHE,
605
+ integer: true
606
+ })),
607
+ VNumber("clahe.height", {
608
+ min: 1,
609
+ max: MAX_CLAHE,
610
+ integer: true
611
+ }),
612
+ VNumber("clahe.maxSlope", {
613
+ min: 0,
614
+ max: 100,
615
+ integer: true
616
+ })
257
617
  ],
618
+ apply: (context, pipe, width, height, maxSlope) => {
619
+ return pipe.clahe({
620
+ width: Math.min(width, context.meta.width || width),
621
+ height: Math.min(height ?? width, (context.meta.height || height) ?? width),
622
+ ...maxSlope === void 0 ? {} : { maxSlope }
623
+ });
624
+ }
625
+ };
626
+ const flatten = {
627
+ args: [],
258
628
  apply: (context, pipe) => {
259
629
  return pipe.flatten({ background: context.background });
260
630
  }
261
631
  };
632
+ const unflatten = {
633
+ args: [],
634
+ apply: (_context, pipe) => {
635
+ return pipe.unflatten();
636
+ }
637
+ };
262
638
  const gamma = {
263
- args: [
264
- VArg,
265
- VArg,
266
- VArg
267
- ],
268
- apply: (_context, pipe, gamma$1, gammaOut) => {
269
- return pipe.gamma(gamma$1, gammaOut);
639
+ args: [VNumber("gamma", {
640
+ min: 1,
641
+ max: 3
642
+ }), VNumber("gamma.gammaOut", {
643
+ min: 1,
644
+ max: 3
645
+ })],
646
+ apply: (_context, pipe, gamma, gammaOut) => {
647
+ return pipe.gamma(gamma, gammaOut);
270
648
  }
271
649
  };
272
650
  const negate = {
273
- args: [
274
- VArg,
275
- VArg,
276
- VArg
277
- ],
278
- apply: (_context, pipe) => {
279
- return pipe.negate();
651
+ args: [VBoolean("negate.alpha")],
652
+ apply: (_context, pipe, alpha) => {
653
+ return alpha === void 0 ? pipe.negate() : pipe.negate({ alpha });
280
654
  }
281
655
  };
282
656
  const normalize = {
283
- args: [
284
- VArg,
285
- VArg,
286
- VArg
287
- ],
288
- apply: (_context, pipe) => {
289
- return pipe.normalize();
657
+ args: [VNumber("normalize.lower", {
658
+ min: 0,
659
+ max: 99
660
+ }), VNumber("normalize.upper", {
661
+ min: 1,
662
+ max: 100
663
+ })],
664
+ apply: (_context, pipe, lower, upper) => {
665
+ return pipe.normalize({
666
+ lower,
667
+ upper
668
+ });
290
669
  }
291
670
  };
292
671
  const threshold = {
293
- args: [VArg],
294
- apply: (_context, pipe, threshold$1) => {
295
- return pipe.threshold(threshold$1);
672
+ args: [VNumber("threshold", {
673
+ min: 0,
674
+ max: 255,
675
+ integer: true
676
+ }), VBoolean("threshold.grayscale")],
677
+ apply: (_context, pipe, threshold, grayscale) => {
678
+ return grayscale === void 0 ? pipe.threshold(threshold) : pipe.threshold(threshold, { grayscale });
679
+ }
680
+ };
681
+ const linear = {
682
+ args: [VNumber("linear.a"), VNumber("linear.b")],
683
+ apply: (_context, pipe, a, b) => {
684
+ return pipe.linear(a, b);
296
685
  }
297
686
  };
298
687
  const modulate = {
299
- args: [VArg],
300
- apply: (_context, pipe, brightness, saturation, hue) => {
688
+ args: [
689
+ VNumber("modulate.brightness", { min: 0 }),
690
+ VNumber("modulate.saturation", { min: 0 }),
691
+ VNumber("modulate.hue", { integer: true }),
692
+ VNumber("modulate.lightness")
693
+ ],
694
+ apply: (_context, pipe, brightness, saturation, hue, lightness) => {
301
695
  return pipe.modulate({
302
- brightness,
303
- saturation,
304
- hue
696
+ ...brightness === void 0 ? {} : { brightness },
697
+ ...saturation === void 0 ? {} : { saturation },
698
+ ...hue === void 0 ? {} : { hue },
699
+ ...lightness === void 0 ? {} : { lightness }
305
700
  });
306
701
  }
307
702
  };
703
+ const brightness = {
704
+ args: [VRequired("brightness", VNumber("brightness", { min: 0 }))],
705
+ apply: (_context, pipe, brightness) => {
706
+ return pipe.modulate({ brightness });
707
+ }
708
+ };
709
+ const saturation = {
710
+ args: [VRequired("saturation", VNumber("saturation", { min: 0 }))],
711
+ apply: (_context, pipe, saturation) => {
712
+ return pipe.modulate({ saturation });
713
+ }
714
+ };
715
+ const hue = {
716
+ args: [VRequired("hue", VNumber("hue", { integer: true }))],
717
+ apply: (_context, pipe, hue) => {
718
+ return pipe.modulate({ hue });
719
+ }
720
+ };
721
+ const lightness = {
722
+ args: [VRequired("lightness", VNumber("lightness"))],
723
+ apply: (_context, pipe, lightness) => {
724
+ return pipe.modulate({ lightness });
725
+ }
726
+ };
727
+ const opacity = {
728
+ args: [VRequired("opacity", VNumber("opacity", {
729
+ min: 0,
730
+ max: 1
731
+ }))],
732
+ apply: (context, pipe, opacity) => {
733
+ const overlay = opacityOverlay(opacity, context.background);
734
+ return (context.background ? pipe : pipe.ensureAlpha()).composite([overlay]);
735
+ }
736
+ };
308
737
  const tint = {
309
- args: [VArg],
738
+ args: [VColor("tint")],
310
739
  apply: (_context, pipe, rgb) => {
311
740
  return pipe.tint(rgb);
312
741
  }
313
742
  };
314
743
  const grayscale = {
315
- args: [VArg],
744
+ args: [],
316
745
  apply: (_context, pipe) => {
317
746
  return pipe.grayscale();
318
747
  }
@@ -324,12 +753,94 @@ const w = width;
324
753
  const h = height;
325
754
  const s = resize;
326
755
  const pos = position;
327
-
328
- //#endregion
329
- //#region src/utils.ts
756
+ const UNSAFE_ELEMENTS = /* @__PURE__ */ new Set([
757
+ "script",
758
+ "foreignobject",
759
+ "iframe",
760
+ "embed",
761
+ "object",
762
+ "handler",
763
+ "listener",
764
+ "base",
765
+ "link",
766
+ "meta"
767
+ ]);
768
+ const ANIMATION_ELEMENTS = /* @__PURE__ */ new Set([
769
+ "animate",
770
+ "animatemotion",
771
+ "animatetransform",
772
+ "set"
773
+ ]);
774
+ const ANIMATION_VALUE_ATTRS = [
775
+ "from",
776
+ "to",
777
+ "by",
778
+ "values"
779
+ ];
780
+ const URI_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
781
+ const SAFE_URI_SCHEMES = /* @__PURE__ */ new Set([
782
+ "http",
783
+ "https",
784
+ "mailto",
785
+ "tel",
786
+ "ftp"
787
+ ]);
788
+ const SAFE_DATA_URI_RE = /^data:image\/(?!svg\+xml)[\w.+-]+[,;]/;
789
+ const URI_SCHEME_RE = /^([a-z][\d+.a-z-]*):/;
790
+ const URI_IGNORED_CHARS_RE = /[\u0000-\u0020]/g;
791
+ function localName(name) {
792
+ const colonIndex = name.indexOf(":");
793
+ return (colonIndex === -1 ? name : name.slice(colonIndex + 1)).toLowerCase();
794
+ }
795
+ function isUnsafeURI(value) {
796
+ const uri = value.replace(URI_IGNORED_CHARS_RE, "").toLowerCase();
797
+ const scheme = URI_SCHEME_RE.exec(uri)?.[1];
798
+ if (!scheme) return false;
799
+ if (scheme === "data") return !SAFE_DATA_URI_RE.test(uri);
800
+ return !SAFE_URI_SCHEMES.has(scheme);
801
+ }
802
+ function isUnsafeAnimation(attributes) {
803
+ const attrs = {};
804
+ for (const [name, value] of Object.entries(attributes)) attrs[localName(name)] = value;
805
+ if (localName(attrs.attributename?.trim() || "").startsWith("on")) return true;
806
+ return ANIMATION_VALUE_ATTRS.some((attr) => (attrs[attr] || "").split(";").some((value) => isUnsafeURI(value)));
807
+ }
808
+ function detach(node, parent) {
809
+ parent.children = parent.children.filter((child) => child !== node);
810
+ }
811
+ const sanitizeSVGPlugin = {
812
+ name: "ipx-sanitize",
813
+ fn: () => ({
814
+ doctype: { enter: (node, parentNode) => detach(node, parentNode) },
815
+ instruction: { enter: (node, parentNode) => detach(node, parentNode) },
816
+ element: { enter: (node, parentNode) => {
817
+ const name = localName(node.name);
818
+ if (UNSAFE_ELEMENTS.has(name)) {
819
+ detach(node, parentNode);
820
+ return;
821
+ }
822
+ if (ANIMATION_ELEMENTS.has(name) && isUnsafeAnimation(node.attributes)) {
823
+ detach(node, parentNode);
824
+ return;
825
+ }
826
+ for (const [attr, value] of Object.entries(node.attributes)) {
827
+ const attrName = localName(attr);
828
+ if (attrName.startsWith("on") || URI_ATTRS.has(attrName) && isUnsafeURI(value)) delete node.attributes[attr];
829
+ }
830
+ } }
831
+ })
832
+ };
330
833
  function getEnv(name) {
331
834
  const value = globalThis.process?.env?.[name];
332
- if (value !== void 0) return JSON.parse(value);
835
+ if (value === void 0) return;
836
+ try {
837
+ return JSON.parse(value);
838
+ } catch {
839
+ return value;
840
+ }
841
+ }
842
+ function getBuiltinModule(id) {
843
+ return globalThis.process?.getBuiltinModule?.(id);
333
844
  }
334
845
  function requireModule(id) {
335
846
  const { createRequire } = globalThis.process.getBuiltinModule("node:module");
@@ -343,10 +854,8 @@ function cachedPromise(function_) {
343
854
  return p;
344
855
  };
345
856
  }
346
-
347
- //#endregion
348
- //#region src/ipx.ts
349
- const SUPPORTED_FORMATS = new Set([
857
+ const DEFAULT_MAX_OUTPUT_DIMENSION = 8192;
858
+ const SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
350
859
  "jpeg",
351
860
  "png",
352
861
  "webp",
@@ -356,17 +865,12 @@ const SUPPORTED_FORMATS = new Set([
356
865
  "gif",
357
866
  "heic"
358
867
  ]);
359
- /**
360
- * Creates an IPX image processing instance with the specified options.
361
- * @param {IPXOptions} userOptions - Configuration options for the IPX instance. See {@link IPXOptions}.
362
- * @returns {IPX} An IPX processing function configured with the given options. See {@link IPX}.
363
- * @throws {Error} If critical options such as storage are missing or incorrectly configured.
364
- */
365
868
  function createIPX(userOptions) {
366
869
  const options = {
367
870
  ...userOptions,
368
871
  alias: userOptions.alias || getEnv("IPX_ALIAS") || {},
369
872
  maxAge: userOptions.maxAge ?? getEnv("IPX_MAX_AGE") ?? 60,
873
+ maxOutputDimension: userOptions.maxOutputDimension ?? getEnv("IPX_MAX_OUTPUT_DIMENSION") ?? DEFAULT_MAX_OUTPUT_DIMENSION,
370
874
  sharpOptions: {
371
875
  jpegProgressive: true,
372
876
  ...userOptions.sharpOptions
@@ -377,7 +881,7 @@ function createIPX(userOptions) {
377
881
  return await import("sharp").then((r) => r.default || r);
378
882
  });
379
883
  const getSVGO = cachedPromise(async () => {
380
- const { optimize } = await import("./svgo-node.mjs").then(__toDynamicImportESM(1));
884
+ const { optimize } = await import("./svgo-node.mjs").then((m) => /* @__PURE__ */ __toESM(m.default, 1));
381
885
  return { optimize };
382
886
  });
383
887
  return function ipx(id, modifiers = {}, opts = {}) {
@@ -432,25 +936,43 @@ function createIPX(userOptions) {
432
936
  }
433
937
  let mFormat = modifiers.f || modifiers.format;
434
938
  if (mFormat === "jpg") mFormat = "jpeg";
435
- const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
436
- if (imageMeta$1.type === "svg" && !mFormat) if (options.svgo === false) return {
437
- data: sourceData,
438
- format: "svg+xml",
439
- meta: imageMeta$1
440
- };
441
- else {
939
+ if (imageMeta$1.type === "svg" && (!mFormat || mFormat === "svg")) {
940
+ const sanitize = !options.svg?.unsafeSkipSanitize;
941
+ const shouldOptimize = options.svg?.optimize !== false;
942
+ const svgoConfig = options.svg?.optimize === false ? void 0 : options.svg?.optimize;
943
+ if (!sanitize && !shouldOptimize) return {
944
+ data: sourceData,
945
+ format: "svg+xml",
946
+ meta: imageMeta$1
947
+ };
948
+ const plugins = sanitize ? ["removeScripts", sanitizeSVGPlugin] : [];
949
+ if (svgoConfig?.plugins) plugins.push(...svgoConfig.plugins);
950
+ else if (shouldOptimize) plugins.push("preset-default");
442
951
  const { optimize } = await getSVGO();
952
+ let svg;
953
+ try {
954
+ svg = optimize(sourceData.toString("utf8"), {
955
+ ...svgoConfig,
956
+ plugins
957
+ }).data;
958
+ } catch (error) {
959
+ throw new HTTPError({
960
+ statusCode: 400,
961
+ statusText: `IPX_INVALID_SVG`,
962
+ message: `Cannot parse SVG: ${id}`,
963
+ cause: error
964
+ });
965
+ }
443
966
  return {
444
- data: optimize(sourceData.toString("utf8"), {
445
- ...options.svgo,
446
- plugins: ["removeScripts", ...options.svgo?.plugins || []]
447
- }).data,
967
+ data: svg,
448
968
  format: "svg+xml",
449
969
  meta: imageMeta$1
450
970
  };
451
971
  }
972
+ const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
452
973
  const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
453
- let sharp = (await getSharp())(sourceData, {
974
+ const Sharp = await getSharp();
975
+ let sharp = Sharp(sourceData, {
454
976
  animated,
455
977
  ...options.sharpOptions
456
978
  });
@@ -459,16 +981,35 @@ function createIPX(userOptions) {
459
981
  handler: getHandler(name),
460
982
  name,
461
983
  args: arguments_
462
- })).filter((h$1) => h$1.handler).sort((a, b$1) => {
463
- const aKey = (a.handler.order || a.name || "").toString();
464
- const bKey = (b$1.handler.order || b$1.name || "").toString();
465
- return aKey.localeCompare(bKey);
984
+ })).filter((h) => h.handler).sort((a, b) => {
985
+ const aOrder = a.handler.order || 0;
986
+ const bOrder = b.handler.order || 0;
987
+ return aOrder === bOrder ? (a.name || "").localeCompare(b.name || "") : aOrder - bOrder;
466
988
  });
467
- const handlerContext = { meta: imageMeta$1 };
468
- for (const h$1 of handlers) sharp = applyHandler(handlerContext, sharp, h$1.handler, h$1.args.toString()) || sharp;
989
+ const handlerContext = {
990
+ meta: imageMeta$1,
991
+ maxOutputDimension: options.maxOutputDimension
992
+ };
993
+ for (const h of handlers) sharp = applyHandler(handlerContext, sharp, h.handler, h.args.toString()) || sharp;
994
+ if (handlerContext.background) try {
995
+ Sharp({ create: {
996
+ width: 1,
997
+ height: 1,
998
+ channels: 4,
999
+ background: handlerContext.background
1000
+ } });
1001
+ } catch (error) {
1002
+ throw asModifierError(error);
1003
+ }
469
1004
  if (SUPPORTED_FORMATS.has(format || "")) sharp = sharp.toFormat(format, { quality: handlerContext.quality });
1005
+ let processedImage;
1006
+ try {
1007
+ processedImage = await sharp.toBuffer();
1008
+ } catch (error) {
1009
+ throw asModifierError(error);
1010
+ }
470
1011
  return {
471
- data: await sharp.toBuffer(),
1012
+ data: processedImage,
472
1013
  format,
473
1014
  meta: imageMeta$1
474
1015
  };
@@ -476,47 +1017,56 @@ function createIPX(userOptions) {
476
1017
  };
477
1018
  };
478
1019
  }
479
-
480
- //#endregion
481
- //#region src/server.ts
482
1020
  var import_etag = /* @__PURE__ */ __toESM(require_etag(), 1);
483
1021
  var import_accept_negotiator = require_accept_negotiator();
484
- function createIPXFetchHandler(ipx) {
485
- return createIPXHandler(ipx).fetch;
1022
+ function createIPXFetchHandler(ipx, opts) {
1023
+ return createIPXHandler(ipx, opts).fetch;
486
1024
  }
487
- function createIPXNodeHandler(ipx) {
1025
+ function createIPXNodeHandler(ipx, opts) {
488
1026
  const { toNodeHandler } = requireModule("srvx/node");
489
- return toNodeHandler(createIPXFetchHandler(ipx));
1027
+ return toNodeHandler(createIPXFetchHandler(ipx, opts));
490
1028
  }
491
1029
  function serveIPX(ipx, opts) {
492
1030
  const { serve } = requireModule("srvx");
493
- const fetch$1 = createIPXFetchHandler(ipx);
1031
+ const { parseURL, ...serverOptions } = opts || {};
1032
+ const fetch = createIPXFetchHandler(ipx, { parseURL });
494
1033
  return serve({
495
- ...opts,
496
- fetch: fetch$1
1034
+ ...serverOptions,
1035
+ fetch
497
1036
  });
498
1037
  }
499
1038
  const MODIFIER_SEP = /[&,]/g;
500
1039
  const MODIFIER_VAL_SEP = /[:=_]/;
501
- function createIPXHandler(ipx) {
1040
+ function parseIPXURL(url) {
1041
+ const [modifiersString = "", ...idSegments] = new URL(url).pathname.slice(1).split("/");
1042
+ const id = decode$1(idSegments.join("/"));
1043
+ if (!modifiersString) throw new HTTPError({
1044
+ statusCode: 400,
1045
+ statusText: "IPX_MISSING_MODIFIERS",
1046
+ message: `Modifiers are missing: ${safeString(id)}`
1047
+ });
1048
+ const modifiers = Object.create(null);
1049
+ if (modifiersString !== "_") for (const p of modifiersString.split(MODIFIER_SEP)) {
1050
+ const [key = "", ...values] = p.split(MODIFIER_VAL_SEP);
1051
+ modifiers[key] = values.map((v) => decode$1(v)).join("_");
1052
+ }
1053
+ return {
1054
+ id,
1055
+ modifiers
1056
+ };
1057
+ }
1058
+ function createIPXHandler(ipx, opts = {}) {
1059
+ const parseURL = opts.parseURL || parseIPXURL;
502
1060
  return defineEventHandler(async (event) => {
503
- const [modifiersString = "", ...idSegments] = event.url.pathname.slice(1).split("/");
504
- const id = safeString(decode(idSegments.join("/")));
505
- if (!modifiersString) throw new HTTPError({
506
- statusCode: 400,
507
- statusText: "IPX_MISSING_MODIFIERS",
508
- message: `Modifiers are missing: ${id}`
509
- });
1061
+ const parsed = await parseURL(event.req.url) || {};
1062
+ const id = safeString(parsed.id);
510
1063
  if (!id || id === "/") throw new HTTPError({
511
1064
  statusCode: 400,
512
1065
  statusText: "IPX_MISSING_ID",
513
1066
  message: `Resource id is missing: ${event.path}`
514
1067
  });
515
1068
  const modifiers = Object.create(null);
516
- if (modifiersString !== "_") for (const p of modifiersString.split(MODIFIER_SEP)) {
517
- const [key, ...values] = p.split(MODIFIER_VAL_SEP);
518
- modifiers[safeString(key)] = values.map((v) => safeString(decode(v))).join("_");
519
- }
1069
+ for (const [key, value] of Object.entries(parsed.modifiers || {})) modifiers[safeString(key)] = safeString(value);
520
1070
  if ((modifiers.f || modifiers.format) === "auto") {
521
1071
  const acceptHeader = event.req.headers.get("accept") || "";
522
1072
  const animated = modifiers.animated ?? modifiers.a;
@@ -531,6 +1081,9 @@ function createIPXHandler(ipx) {
531
1081
  const img = ipx(id, modifiers);
532
1082
  const sourceMeta = await img.getSourceMeta();
533
1083
  sendResponseHeaderIfNotSet(event, "content-security-policy", "default-src 'none'");
1084
+ sendResponseHeaderIfNotSet(event, "x-content-type-options", "nosniff");
1085
+ if (typeof sourceMeta.maxAge === "number") sendResponseHeaderIfNotSet(event, "cache-control", `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`);
1086
+ const ifNoneMatch = event.req.headers.get("if-none-match");
534
1087
  if (sourceMeta.mtime) {
535
1088
  sendResponseHeaderIfNotSet(event, "last-modified", sourceMeta.mtime.toUTCString());
536
1089
  const _ifModifiedSince = event.req.headers.get("if-modified-since");
@@ -539,13 +1092,22 @@ function createIPXHandler(ipx) {
539
1092
  return;
540
1093
  }
541
1094
  }
1095
+ const weakEtag = sourceMeta.mtime ? getWeakEtag(id, modifiers, sourceMeta.mtime) : void 0;
1096
+ if (weakEtag) {
1097
+ sendResponseHeaderIfNotSet(event, "etag", weakEtag);
1098
+ if (etagMatches(ifNoneMatch, weakEtag)) {
1099
+ event.res.status = 304;
1100
+ return;
1101
+ }
1102
+ }
542
1103
  const { data, format } = await img.process();
543
- if (typeof sourceMeta.maxAge === "number") sendResponseHeaderIfNotSet(event, "cache-control", `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`);
544
- const etag = (0, import_etag.default)(data);
545
- sendResponseHeaderIfNotSet(event, "etag", etag);
546
- if (etag && event.req.headers.get("if-none-match") === etag) {
547
- event.res.status = 304;
548
- return;
1104
+ if (!weakEtag) {
1105
+ const etag = (0, import_etag.default)(data);
1106
+ sendResponseHeaderIfNotSet(event, "etag", etag);
1107
+ if (etagMatches(ifNoneMatch, etag)) {
1108
+ event.res.status = 304;
1109
+ return;
1110
+ }
549
1111
  }
550
1112
  if (format) sendResponseHeaderIfNotSet(event, "content-type", `image/${format}`);
551
1113
  return data;
@@ -554,6 +1116,25 @@ function createIPXHandler(ipx) {
554
1116
  function sendResponseHeaderIfNotSet(event, name, value) {
555
1117
  if (!event.res.headers.has(name)) event.res.headers.set(name, value);
556
1118
  }
1119
+ function getWeakEtag(id, modifiers, mtime) {
1120
+ const crypto = getBuiltinModule("node:crypto");
1121
+ if (!crypto?.createHash) return;
1122
+ const key = JSON.stringify([
1123
+ mtime.getTime(),
1124
+ id,
1125
+ Object.keys(modifiers).sort().map((key) => [key, modifiers[key]])
1126
+ ]);
1127
+ return `W/"${crypto.createHash("sha256").update(key).digest("base64url").slice(0, 27)}"`;
1128
+ }
1129
+ function etagMatches(ifNoneMatch, etag) {
1130
+ if (!ifNoneMatch || !etag) return false;
1131
+ if (ifNoneMatch === "*") return true;
1132
+ const opaque = opaqueTag(etag);
1133
+ return ifNoneMatch.split(",").some((tag) => opaqueTag(tag.trim()) === opaque);
1134
+ }
1135
+ function opaqueTag(tag) {
1136
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
1137
+ }
557
1138
  function autoDetectFormat(acceptHeader, animated) {
558
1139
  if (animated) return (0, import_accept_negotiator.negotiate)(acceptHeader, ["image/webp", "image/gif"])?.split("/")[1] || "gif";
559
1140
  return (0, import_accept_negotiator.negotiate)(acceptHeader, [
@@ -566,44 +1147,225 @@ function autoDetectFormat(acceptHeader, animated) {
566
1147
  "image/gif"
567
1148
  ])?.split("/")[1] || "jpeg";
568
1149
  }
1150
+ function decode$1(input) {
1151
+ try {
1152
+ return decodeURIComponent(input);
1153
+ } catch {
1154
+ return input;
1155
+ }
1156
+ }
569
1157
  function safeString(input) {
570
- return JSON.stringify(input).replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
1158
+ return JSON.stringify(input ?? "").replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
571
1159
  }
572
-
573
- //#endregion
574
- //#region src/storage/http.ts
575
1160
  const HTTP_RE = /^https?:\/\//;
576
- /**
577
- * Creates an HTTP storage handler for IPX that fetches image data from external URLs.
578
- * This handler allows configuration to specify allowed domains, caching behaviour and custom fetch options.
579
- *
580
- * @param {HTTPStorageOptions} [_options={}] - Configuration options for HTTP storage, with defaults possibly taken from environment variables. See {@link HTTPStorageOptions}.
581
- * @returns {IPXStorage} An IPXStorage interface implementation for retrieving images over HTTP. See {@link IPXStorage}.
582
- * @throws {H3Error} If validation of the requested URL fails due to a missing hostname or denied host access. See {@link H3Error}.
583
- */
1161
+ const REDIRECT_STATUS = /* @__PURE__ */ new Set([
1162
+ 301,
1163
+ 302,
1164
+ 303,
1165
+ 307,
1166
+ 308
1167
+ ]);
1168
+ const MAX_REDIRECTS = 3;
1169
+ function parseIPv4(input) {
1170
+ const parts = input.split(".");
1171
+ if (parts.length !== 4) return;
1172
+ const octets = [];
1173
+ for (const part of parts) {
1174
+ if (!/^\d{1,3}$/.test(part)) return;
1175
+ const octet = Number(part);
1176
+ if (octet > 255) return;
1177
+ octets.push(octet);
1178
+ }
1179
+ return octets;
1180
+ }
1181
+ function parseIPv6(input) {
1182
+ const zoneIndex = input.indexOf("%");
1183
+ const halves = (zoneIndex === -1 ? input : input.slice(0, zoneIndex)).split("::");
1184
+ if (halves.length > 2) return;
1185
+ const expand = (part) => {
1186
+ if (part === "") return [];
1187
+ const bytes = [];
1188
+ const groups = part.split(":");
1189
+ for (const [index, group] of groups.entries()) {
1190
+ if (group.includes(".")) {
1191
+ if (index !== groups.length - 1) return;
1192
+ const octets = parseIPv4(group);
1193
+ if (!octets) return;
1194
+ bytes.push(...octets);
1195
+ continue;
1196
+ }
1197
+ if (!/^[\da-f]{1,4}$/i.test(group)) return;
1198
+ const value = Number.parseInt(group, 16);
1199
+ bytes.push(value >> 8, value & 255);
1200
+ }
1201
+ return bytes;
1202
+ };
1203
+ const head = expand(halves[0]);
1204
+ const tail = halves.length === 2 ? expand(halves[1]) : [];
1205
+ if (!head || !tail) return;
1206
+ if (halves.length === 1) return head.length === 16 ? head : void 0;
1207
+ const fill = 16 - head.length - tail.length;
1208
+ if (fill < 0) return;
1209
+ return [
1210
+ ...head,
1211
+ ...Array.from({ length: fill }, () => 0),
1212
+ ...tail
1213
+ ];
1214
+ }
1215
+ function embeddedIPv4(bytes) {
1216
+ if (bytes.slice(0, 10).every((byte) => byte === 0) && (bytes[10] === 255 && bytes[11] === 255 || bytes[10] === 0 && bytes[11] === 0)) return bytes.slice(12);
1217
+ if (bytes.slice(0, 8).every((byte) => byte === 0) && bytes[8] === 255 && bytes[9] === 255 && bytes[10] === 0 && bytes[11] === 0) return bytes.slice(12);
1218
+ if (bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155 && bytes.slice(4, 12).every((byte) => byte === 0)) return bytes.slice(12);
1219
+ if (bytes[0] === 32 && bytes[1] === 2) return bytes.slice(2, 6);
1220
+ }
1221
+ function isPublicIPv4(octets) {
1222
+ const [a, b, c] = octets;
1223
+ return !(a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 && c === 0 || a === 192 && b === 0 && c === 2 || a === 192 && b === 88 && c === 99 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a >= 224);
1224
+ }
1225
+ function isPublicIPv6(bytes) {
1226
+ const embedded = embeddedIPv4(bytes);
1227
+ if (embedded) return isPublicIPv4(embedded);
1228
+ const [a, b] = bytes;
1229
+ return !(a === 255 || a === 254 && (b & 192) === 128 || a === 254 && (b & 192) === 192 || (a & 254) === 252 || a === 0 && b === 100 && bytes[2] === 255 && bytes[3] === 155 || a === 1 && b === 0 && bytes.slice(2, 8).every((x) => x === 0) || a === 32 && b === 1 && bytes[2] === 13 && bytes[3] === 184 || a === 32 && b === 1 && bytes[2] === 0 && bytes[3] === 0);
1230
+ }
1231
+ function isPublicIP(address, family) {
1232
+ if (family === 4) {
1233
+ const octets = parseIPv4(address);
1234
+ return octets ? isPublicIPv4(octets) : false;
1235
+ }
1236
+ if (family === 6) {
1237
+ const bytes = parseIPv6(address);
1238
+ return bytes ? isPublicIPv6(bytes) : false;
1239
+ }
1240
+ return false;
1241
+ }
1242
+ function decode(input) {
1243
+ try {
1244
+ return decodeURIComponent(input);
1245
+ } catch {
1246
+ return input;
1247
+ }
1248
+ }
584
1249
  function ipxHttpStorage(_options = {}) {
585
1250
  const allowAllDomains = _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false;
586
1251
  let _domains = _options.domains || getEnv("IPX_HTTP_DOMAINS") || [];
587
1252
  const defaultMaxAge = _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300;
588
1253
  const fetchOptions = _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {};
589
- if (typeof _domains === "string") _domains = _domains.split(",").map((s$1) => s$1.trim());
1254
+ const blockPrivateIPs = _options.blockPrivateIPs ?? getEnv("IPX_HTTP_BLOCK_PRIVATE_IPS") ?? false;
1255
+ if (typeof _domains === "string") _domains = _domains.split(",").map((s) => s.trim());
590
1256
  const domains = new Set(_domains.map((d) => {
591
1257
  if (!HTTP_RE.test(d)) d = "http://" + d;
592
1258
  return new URL(d).hostname;
593
1259
  }).filter(Boolean));
594
- function validateId(id) {
595
- const url = new URL(decodeURIComponent(id));
1260
+ async function validateId(id) {
1261
+ let url;
1262
+ try {
1263
+ url = new URL(decode(id));
1264
+ } catch {
1265
+ throw new HTTPError({
1266
+ statusCode: 400,
1267
+ statusText: `IPX_INVALID_URL`,
1268
+ message: `Invalid URL: ${id}`
1269
+ });
1270
+ }
1271
+ await validateURL(url, id);
1272
+ return url.toString();
1273
+ }
1274
+ async function validateURL(url, id = url.toString()) {
596
1275
  if (!url.hostname) throw new HTTPError({
597
1276
  statusCode: 403,
598
1277
  statusText: `IPX_MISSING_HOSTNAME`,
599
1278
  message: `Hostname is missing: ${id}`
600
1279
  });
1280
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new HTTPError({
1281
+ statusCode: 403,
1282
+ statusText: `IPX_FORBIDDEN_PROTOCOL`,
1283
+ message: `Forbidden protocol: ${url.protocol}`
1284
+ });
601
1285
  if (!allowAllDomains && !domains.has(url.hostname)) throw new HTTPError({
602
1286
  statusCode: 403,
603
1287
  statusText: `IPX_FORBIDDEN_HOST`,
604
1288
  message: `Forbidden host: ${url.hostname}`
605
1289
  });
606
- return url.toString();
1290
+ if (blockPrivateIPs) await validatePublicIP(url, id);
1291
+ }
1292
+ async function validatePublicIP(url, id) {
1293
+ const hostname = url.hostname.replace(/^\[|]$/g, "");
1294
+ const net = getBuiltinModule("node:net");
1295
+ const dns = getBuiltinModule("node:dns");
1296
+ if (!net?.isIP || !dns?.promises?.lookup) throw new HTTPError({
1297
+ statusCode: 500,
1298
+ statusText: `IPX_IP_CHECK_UNAVAILABLE`,
1299
+ message: `Cannot verify the address of ${hostname}: \`blockPrivateIPs\` requires \`node:net\` and \`node:dns\`.`
1300
+ });
1301
+ const assertPublic = (address, family) => {
1302
+ if (!isPublicIP(address, family)) throw new HTTPError({
1303
+ statusCode: 403,
1304
+ statusText: `IPX_FORBIDDEN_IP`,
1305
+ message: `Forbidden IP address: ${address} (${id})`
1306
+ });
1307
+ };
1308
+ const family = net.isIP(hostname);
1309
+ if (family) {
1310
+ assertPublic(hostname, family);
1311
+ return;
1312
+ }
1313
+ let addresses;
1314
+ try {
1315
+ addresses = await dns.promises.lookup(hostname, {
1316
+ all: true,
1317
+ verbatim: true
1318
+ });
1319
+ } catch {
1320
+ throw new HTTPError({
1321
+ statusCode: 502,
1322
+ statusText: `IPX_DNS_LOOKUP_FAILED`,
1323
+ message: `Cannot resolve host: ${hostname}`
1324
+ });
1325
+ }
1326
+ if (addresses.length === 0) throw new HTTPError({
1327
+ statusCode: 502,
1328
+ statusText: `IPX_DNS_LOOKUP_FAILED`,
1329
+ message: `Cannot resolve host: ${hostname}`
1330
+ });
1331
+ for (const { address, family } of addresses) assertPublic(address, family || net.isIP(address));
1332
+ }
1333
+ async function fetchURL(url, init) {
1334
+ const _init = {
1335
+ ...fetchOptions,
1336
+ ...init
1337
+ };
1338
+ if (allowAllDomains && !blockPrivateIPs || _init.redirect) return fetch(url, _init);
1339
+ let currentURL = url;
1340
+ let method = _init.method || "GET";
1341
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
1342
+ const response = await fetch(currentURL, {
1343
+ ..._init,
1344
+ method,
1345
+ redirect: "manual"
1346
+ });
1347
+ if (!REDIRECT_STATUS.has(response.status)) return response;
1348
+ const location = response.headers.get("location");
1349
+ if (!location) return response;
1350
+ let nextURL;
1351
+ try {
1352
+ nextURL = new URL(location, currentURL);
1353
+ } catch {
1354
+ throw new HTTPError({
1355
+ statusCode: 502,
1356
+ statusText: `IPX_INVALID_REDIRECT`,
1357
+ message: `Invalid redirect location: ${location}`
1358
+ });
1359
+ }
1360
+ await validateURL(nextURL, location);
1361
+ if (response.status === 303 && method !== "HEAD") method = "GET";
1362
+ currentURL = nextURL.toString();
1363
+ }
1364
+ throw new HTTPError({
1365
+ statusCode: 502,
1366
+ statusText: `IPX_TOO_MANY_REDIRECTS`,
1367
+ message: `Too many redirects (max ${MAX_REDIRECTS}): ${url}`
1368
+ });
607
1369
  }
608
1370
  function parseResponse(response) {
609
1371
  let maxAge = defaultMaxAge;
@@ -625,12 +1387,9 @@ function ipxHttpStorage(_options = {}) {
625
1387
  return {
626
1388
  name: "ipx:http",
627
1389
  async getMeta(id) {
628
- const url = validateId(id);
1390
+ const url = await validateId(id);
629
1391
  try {
630
- const response = await fetch(url, {
631
- ...fetchOptions,
632
- method: "HEAD"
633
- });
1392
+ const response = await fetchURL(url, { method: "HEAD" });
634
1393
  if (!response.ok) return {};
635
1394
  const { maxAge, mtime } = parseResponse(response);
636
1395
  return {
@@ -642,8 +1401,7 @@ function ipxHttpStorage(_options = {}) {
642
1401
  }
643
1402
  },
644
1403
  async getData(id) {
645
- const url = validateId(id);
646
- const response = await fetch(url, fetchOptions);
1404
+ const response = await fetchURL(await validateId(id));
647
1405
  if (!response.ok) throw new HTTPError({
648
1406
  statusCode: response.status,
649
1407
  statusText: response.statusText,
@@ -653,44 +1411,66 @@ function ipxHttpStorage(_options = {}) {
653
1411
  }
654
1412
  };
655
1413
  }
656
-
657
- //#endregion
658
- //#region src/storage/node-fs.ts
659
- /**
660
- * Creates a file system storage handler for IPX that allows images to be served from local directories specified in the options.
661
- * This handler resolves directories and handles file access, ensuring that files are served safely.
662
- *
663
- * @param {NodeFSSOptions} [_options={}] - File system storage configuration options, with optional directory paths and caching configuration. See {@link NodeFSSOptions}.
664
- * @returns {IPXStorage} An implementation of the IPXStorage interface for accessing images stored on the local file system. See {@link IPXStorage}.
665
- * @throws {H3Error} If there is a problem accessing the file system module or resolving/reading files. See {@link H3Error}.
666
- */
1414
+ const { join, parse, resolve, relative, isAbsolute, sep } = /* #__PURE__ */ getBuiltinModule("node:path") || {};
1415
+ const NOT_IN_THIS_DIR = /* @__PURE__ */ new Set([
1416
+ "ENOENT",
1417
+ "ENOTDIR",
1418
+ "ELOOP",
1419
+ "ENAMETOOLONG"
1420
+ ]);
667
1421
  function ipxFSStorage(_options = {}) {
1422
+ const fs = getBuiltinModule("node:fs/promises");
1423
+ if (!fs) throw new Error("ipxFSStorage requires the `node:fs/promises` builtin, which this runtime does not provide");
668
1424
  const dirs = resolveDirs(_options.dir);
669
- const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE");
670
- const fs = globalThis.process.getBuiltinModule("node:fs/promises");
1425
+ const maxAge = _options.maxAge ?? getEnv("IPX_FS_MAX_AGE");
1426
+ const allowSymlinksOutsideDir = _options.allowSymlinksOutsideDir ?? getEnv("IPX_FS_ALLOW_SYMLINKS_OUTSIDE_DIR") ?? false;
1427
+ const realDirCache = [];
1428
+ const getRealDir = (index, dir) => {
1429
+ let realDir = realDirCache[index];
1430
+ if (!realDir) {
1431
+ realDir = fs.realpath(dir).catch(() => dir);
1432
+ realDirCache[index] = realDir;
1433
+ }
1434
+ return realDir;
1435
+ };
671
1436
  const resolveFile = async (id) => {
672
- for (const dir of dirs) {
1437
+ for (const [index, dir] of dirs.entries()) {
673
1438
  const filePath = join(dir, id);
674
- if (!isValidPath(filePath) || !filePath.startsWith(dir + "/")) throw new HTTPError({
1439
+ if (!isValidPath(filePath) || !isInsideDir(filePath, dir)) throw new HTTPError({
675
1440
  statusCode: 403,
676
1441
  statusText: `IPX_FORBIDDEN_PATH`,
677
1442
  message: `Forbidden path: ${id}`
678
1443
  });
1444
+ let stats;
1445
+ let realPath;
679
1446
  try {
680
- const stats = await fs.stat(filePath);
681
- if (!stats.isFile()) continue;
682
- return {
683
- stats,
684
- read: () => fs.readFile(filePath)
685
- };
1447
+ stats = await fs.stat(filePath);
1448
+ if (stats.isFile() && !allowSymlinksOutsideDir) realPath = await fs.realpath(filePath);
686
1449
  } catch (error) {
687
- if (error.code === "ENOENT") continue;
1450
+ if (NOT_IN_THIS_DIR.has(error.code)) continue;
688
1451
  throw new HTTPError({
689
1452
  statusCode: 403,
690
1453
  statusText: `IPX_FORBIDDEN_FILE`,
691
1454
  message: `Cannot access file: ${id}`
692
1455
  });
693
1456
  }
1457
+ if (!stats.isFile()) continue;
1458
+ if (realPath !== void 0) {
1459
+ let realDir = await getRealDir(index, dir);
1460
+ if (!isInsideDir(realPath, realDir)) {
1461
+ realDirCache[index] = void 0;
1462
+ realDir = await getRealDir(index, dir);
1463
+ }
1464
+ if (!isInsideDir(realPath, realDir)) throw new HTTPError({
1465
+ statusCode: 403,
1466
+ statusText: `IPX_FORBIDDEN_SYMLINK`,
1467
+ message: `Forbidden symlink: ${id}`
1468
+ });
1469
+ }
1470
+ return {
1471
+ stats,
1472
+ read: () => fs.readFile(realPath ?? filePath)
1473
+ };
694
1474
  }
695
1475
  throw new HTTPError({
696
1476
  statusCode: 404,
@@ -713,16 +1493,22 @@ function ipxFSStorage(_options = {}) {
713
1493
  }
714
1494
  };
715
1495
  }
716
- const isWindows = process.platform === "win32";
1496
+ const isWindows = globalThis.process?.platform === "win32";
717
1497
  function isValidPath(fp) {
718
1498
  if (isWindows) fp = fp.slice(parse(fp).root.length);
719
1499
  if (/["*:<>?|]/.test(fp)) return false;
720
1500
  return true;
721
1501
  }
1502
+ function isInsideDir(filePath, dir) {
1503
+ if (isWindows) {
1504
+ filePath = filePath.toLowerCase();
1505
+ dir = dir.toLowerCase();
1506
+ }
1507
+ const rel = relative(dir, filePath);
1508
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1509
+ }
722
1510
  function resolveDirs(dirs) {
723
1511
  if (!dirs || !Array.isArray(dirs)) return [resolve(dirs || getEnv("IPX_FS_DIR") || ".")];
724
- return dirs.map((dirs$1) => resolve(dirs$1));
1512
+ return dirs.map((dirs) => resolve(dirs));
725
1513
  }
726
-
727
- //#endregion
728
- export { serveIPX as a, createIPXNodeHandler as i, ipxHttpStorage as n, createIPX as o, createIPXFetchHandler as r, ipxFSStorage as t };
1514
+ export { createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, parseIPXURL, serveIPX };