ipx 4.0.0-alpha.2 → 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,10 +1,9 @@
1
1
  import { __exportAll, __toESM } from "./rolldown-runtime.mjs";
2
- import { decode, hasProtocol, joinURL, withLeadingSlash } from "./libs/ufo.mjs";
2
+ import { hasProtocol, joinURL, withLeadingSlash } from "./libs/ufo.mjs";
3
3
  import { HTTPError, defineEventHandler } from "./libs/h3.mjs";
4
4
  import { imageMeta } from "./libs/image-meta.mjs";
5
5
  import { require_etag } from "./libs/etag.mjs";
6
6
  import { require_accept_negotiator } from "./libs/@fastify/accept-negotiator.mjs";
7
- import { join, parse, resolve } from "pathe";
8
7
  function VArg(argument) {
9
8
  if (argument === "Infinity") return Infinity;
10
9
  if (argument === "undefined") return;
@@ -15,6 +14,78 @@ function VArg(argument) {
15
14
  } catch {}
16
15
  return argument;
17
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
+ };
88
+ }
18
89
  function parseArgs(arguments_, mappers) {
19
90
  const vargs = arguments_.split("_");
20
91
  return mappers.map((v, index) => v(vargs[index]));
@@ -24,7 +95,97 @@ 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;
@@ -43,11 +204,16 @@ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimension
43
204
  };
44
205
  }
45
206
  var handlers_exports = /* @__PURE__ */ __exportAll({
207
+ autoorient: () => autoorient,
46
208
  b: () => b,
47
209
  background: () => background,
48
210
  blur: () => blur,
211
+ brightness: () => brightness,
212
+ clahe: () => clahe,
49
213
  crop: () => crop,
214
+ dilate: () => dilate,
50
215
  enlarge: () => enlarge,
216
+ erode: () => erode,
51
217
  extend: () => extend,
52
218
  extract: () => extract,
53
219
  fit: () => fit,
@@ -58,11 +224,15 @@ var handlers_exports = /* @__PURE__ */ __exportAll({
58
224
  grayscale: () => grayscale,
59
225
  h: () => h,
60
226
  height: () => height,
227
+ hue: () => hue,
61
228
  kernel: () => kernel,
229
+ lightness: () => lightness,
230
+ linear: () => linear,
62
231
  median: () => median,
63
232
  modulate: () => modulate,
64
233
  negate: () => negate,
65
234
  normalize: () => normalize,
235
+ opacity: () => opacity,
66
236
  pos: () => pos,
67
237
  position: () => position,
68
238
  q: () => q,
@@ -70,42 +240,92 @@ var handlers_exports = /* @__PURE__ */ __exportAll({
70
240
  resize: () => resize,
71
241
  rotate: () => rotate,
72
242
  s: () => s,
243
+ saturation: () => saturation,
73
244
  sharpen: () => sharpen,
74
245
  threshold: () => threshold,
75
246
  tint: () => tint,
76
247
  trim: () => trim,
248
+ unflatten: () => unflatten,
77
249
  w: () => w,
78
250
  width: () => width
79
251
  });
80
252
  const quality = {
81
- args: [VArg],
253
+ args: [VNumber("quality", {
254
+ min: 1,
255
+ max: 100,
256
+ integer: true
257
+ })],
82
258
  order: -1,
83
259
  apply: (context, _pipe, quality) => {
84
260
  context.quality = quality;
85
261
  }
86
262
  };
87
263
  const fit = {
88
- args: [VArg],
264
+ args: [VEnum("fit", [
265
+ "contain",
266
+ "cover",
267
+ "fill",
268
+ "inside",
269
+ "outside"
270
+ ])],
89
271
  order: -1,
90
272
  apply: (context, _pipe, fit) => {
91
273
  context.fit = fit;
92
274
  }
93
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
+ };
94
318
  const position = {
95
- args: [VArg],
319
+ args: [VPosition("position")],
96
320
  order: -1,
97
321
  apply: (context, _pipe, position) => {
98
322
  context.position = position;
99
323
  }
100
324
  };
101
- const HEX_RE = /^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
102
- const SHORTHEX_RE = /^([\da-f])([\da-f])([\da-f])$/i;
103
325
  const background = {
104
- args: [VArg],
326
+ args: [VColor("background")],
105
327
  order: -1,
106
328
  apply: (context, _pipe, background) => {
107
- background = String(background);
108
- if (!background.startsWith("#") && (HEX_RE.test(background) || SHORTHEX_RE.test(background))) background = "#" + background;
109
329
  context.background = background;
110
330
  }
111
331
  };
@@ -116,33 +336,49 @@ const enlarge = {
116
336
  }
117
337
  };
118
338
  const kernel = {
119
- args: [VArg],
339
+ args: [VEnum("kernel", [
340
+ "nearest",
341
+ "linear",
342
+ "cubic",
343
+ "mitchell",
344
+ "lanczos2",
345
+ "lanczos3",
346
+ "mks2013",
347
+ "mks2021"
348
+ ])],
120
349
  apply: (context, _pipe, kernel) => {
121
350
  context.kernel = kernel;
122
351
  }
123
352
  };
124
353
  const width = {
125
- args: [VArg],
354
+ args: [VNumber("width", {
355
+ min: 1,
356
+ integer: true
357
+ })],
126
358
  apply: (context, pipe, width) => {
127
- return pipe.resize(width, void 0, { withoutEnlargement: !context.enlarge });
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 });
128
363
  }
129
364
  };
130
365
  const height = {
131
- args: [VArg],
366
+ args: [VNumber("height", {
367
+ min: 1,
368
+ integer: true
369
+ })],
132
370
  apply: (context, pipe, height) => {
133
- return pipe.resize(void 0, height, { withoutEnlargement: !context.enlarge });
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 });
134
375
  }
135
376
  };
136
377
  const resize = {
137
- args: [
138
- VArg,
139
- VArg,
140
- VArg
141
- ],
378
+ args: [VSize("resize")],
142
379
  apply: (context, pipe, size) => {
143
- let [width, height] = String(size).split("x").map(Number);
144
- if (!width) return;
145
- if (!height) height = width;
380
+ if (!size) return;
381
+ let { width, height } = size;
146
382
  if (!context.enlarge) {
147
383
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, {
148
384
  width,
@@ -151,7 +387,12 @@ const resize = {
151
387
  width = clamped.width;
152
388
  height = clamped.height;
153
389
  }
154
- return pipe.resize(width, height, {
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, {
155
396
  fit: context.fit,
156
397
  position: context.position,
157
398
  background: context.background,
@@ -160,34 +401,71 @@ const resize = {
160
401
  }
161
402
  };
162
403
  const trim = {
163
- args: [VArg],
404
+ args: [VNumber("trim", { min: 0 })],
164
405
  apply: (_context, pipe, threshold) => {
165
- return pipe.trim(threshold);
406
+ return pipe.trim({ threshold });
166
407
  }
167
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
+ });
168
420
  const extend = {
169
421
  args: [
170
- VArg,
171
- VArg,
172
- VArg,
173
- VArg
422
+ VExtendEdge("extend.top"),
423
+ VExtendEdge("extend.right"),
424
+ VExtendEdge("extend.bottom"),
425
+ VExtendEdge("extend.left"),
426
+ VEnum("extend.extendWith", EXTEND_WITH)
174
427
  ],
175
- apply: (context, pipe, top, right, bottom, left) => {
176
- return pipe.extend({
428
+ order: 1,
429
+ apply: (context, pipe, top, right, bottom, left, extendWith) => {
430
+ const edges = clampExtendEdges(context.maxOutputDimension, {
177
431
  top,
178
- left,
179
- bottom,
180
432
  right,
181
- 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
182
443
  });
183
444
  }
184
445
  };
446
+ const MAX_EXTRACT = 1e8;
185
447
  const extract = {
186
448
  args: [
187
- VArg,
188
- VArg,
189
- VArg,
190
- 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
+ }))
191
469
  ],
192
470
  apply: (_context, pipe, left, top, width, height) => {
193
471
  return pipe.extract({
@@ -199,9 +477,18 @@ const extract = {
199
477
  }
200
478
  };
201
479
  const rotate = {
202
- args: [VArg],
203
- apply: (context, pipe, angel) => {
204
- 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();
205
492
  }
206
493
  };
207
494
  const flip = {
@@ -218,102 +505,243 @@ const flop = {
218
505
  };
219
506
  const sharpen = {
220
507
  args: [
221
- VArg,
222
- VArg,
223
- 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
+ })
224
532
  ],
225
- apply: (_context, pipe, sigma, flat, jagged) => {
226
- return pipe.sharpen({
533
+ apply: (_context, pipe, sigma, flat, jagged, x1, y2, y3) => {
534
+ return sigma === void 0 ? pipe.sharpen() : pipe.sharpen({
227
535
  sigma,
228
536
  m1: flat,
229
- m2: jagged
537
+ m2: jagged,
538
+ x1,
539
+ y2,
540
+ y3
230
541
  });
231
542
  }
232
543
  };
233
544
  const median = {
234
- args: [
235
- VArg,
236
- VArg,
237
- VArg
238
- ],
545
+ args: [VNumber("median", {
546
+ min: 1,
547
+ max: 1e3,
548
+ integer: true
549
+ })],
239
550
  apply: (_context, pipe, size) => {
240
551
  return pipe.median(size);
241
552
  }
242
553
  };
243
554
  const blur = {
244
555
  args: [
245
- VArg,
246
- VArg,
247
- 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
+ })
248
569
  ],
249
- apply: (_context, pipe, sigma) => {
250
- 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
+ });
251
576
  }
252
577
  };
253
- 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 = {
254
601
  args: [
255
- VArg,
256
- VArg,
257
- 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
+ })
258
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: [],
259
628
  apply: (context, pipe) => {
260
629
  return pipe.flatten({ background: context.background });
261
630
  }
262
631
  };
632
+ const unflatten = {
633
+ args: [],
634
+ apply: (_context, pipe) => {
635
+ return pipe.unflatten();
636
+ }
637
+ };
263
638
  const gamma = {
264
- args: [
265
- VArg,
266
- VArg,
267
- VArg
268
- ],
639
+ args: [VNumber("gamma", {
640
+ min: 1,
641
+ max: 3
642
+ }), VNumber("gamma.gammaOut", {
643
+ min: 1,
644
+ max: 3
645
+ })],
269
646
  apply: (_context, pipe, gamma, gammaOut) => {
270
647
  return pipe.gamma(gamma, gammaOut);
271
648
  }
272
649
  };
273
650
  const negate = {
274
- args: [
275
- VArg,
276
- VArg,
277
- VArg
278
- ],
279
- apply: (_context, pipe) => {
280
- return pipe.negate();
651
+ args: [VBoolean("negate.alpha")],
652
+ apply: (_context, pipe, alpha) => {
653
+ return alpha === void 0 ? pipe.negate() : pipe.negate({ alpha });
281
654
  }
282
655
  };
283
656
  const normalize = {
284
- args: [
285
- VArg,
286
- VArg,
287
- VArg
288
- ],
289
- apply: (_context, pipe) => {
290
- 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
+ });
291
669
  }
292
670
  };
293
671
  const threshold = {
294
- args: [VArg],
295
- apply: (_context, pipe, threshold) => {
296
- return pipe.threshold(threshold);
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);
297
685
  }
298
686
  };
299
687
  const modulate = {
300
- args: [VArg],
301
- 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) => {
302
695
  return pipe.modulate({
303
- brightness,
304
- saturation,
305
- hue
696
+ ...brightness === void 0 ? {} : { brightness },
697
+ ...saturation === void 0 ? {} : { saturation },
698
+ ...hue === void 0 ? {} : { hue },
699
+ ...lightness === void 0 ? {} : { lightness }
306
700
  });
307
701
  }
308
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
+ };
309
737
  const tint = {
310
- args: [VArg],
738
+ args: [VColor("tint")],
311
739
  apply: (_context, pipe, rgb) => {
312
740
  return pipe.tint(rgb);
313
741
  }
314
742
  };
315
743
  const grayscale = {
316
- args: [VArg],
744
+ args: [],
317
745
  apply: (_context, pipe) => {
318
746
  return pipe.grayscale();
319
747
  }
@@ -325,9 +753,94 @@ const w = width;
325
753
  const h = height;
326
754
  const s = resize;
327
755
  const pos = position;
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
+ };
328
833
  function getEnv(name) {
329
834
  const value = globalThis.process?.env?.[name];
330
- 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);
331
844
  }
332
845
  function requireModule(id) {
333
846
  const { createRequire } = globalThis.process.getBuiltinModule("node:module");
@@ -341,6 +854,7 @@ function cachedPromise(function_) {
341
854
  return p;
342
855
  };
343
856
  }
857
+ const DEFAULT_MAX_OUTPUT_DIMENSION = 8192;
344
858
  const SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
345
859
  "jpeg",
346
860
  "png",
@@ -356,6 +870,7 @@ function createIPX(userOptions) {
356
870
  ...userOptions,
357
871
  alias: userOptions.alias || getEnv("IPX_ALIAS") || {},
358
872
  maxAge: userOptions.maxAge ?? getEnv("IPX_MAX_AGE") ?? 60,
873
+ maxOutputDimension: userOptions.maxOutputDimension ?? getEnv("IPX_MAX_OUTPUT_DIMENSION") ?? DEFAULT_MAX_OUTPUT_DIMENSION,
359
874
  sharpOptions: {
360
875
  jpegProgressive: true,
361
876
  ...userOptions.sharpOptions
@@ -421,25 +936,43 @@ function createIPX(userOptions) {
421
936
  }
422
937
  let mFormat = modifiers.f || modifiers.format;
423
938
  if (mFormat === "jpg") mFormat = "jpeg";
424
- const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
425
- if (imageMeta$1.type === "svg" && !mFormat) if (options.svgo === false) return {
426
- data: sourceData,
427
- format: "svg+xml",
428
- meta: imageMeta$1
429
- };
430
- 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");
431
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
+ }
432
966
  return {
433
- data: optimize(sourceData.toString("utf8"), {
434
- ...options.svgo,
435
- plugins: ["removeScripts", ...options.svgo?.plugins || []]
436
- }).data,
967
+ data: svg,
437
968
  format: "svg+xml",
438
969
  meta: imageMeta$1
439
970
  };
440
971
  }
972
+ const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
441
973
  const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
442
- let sharp = (await getSharp())(sourceData, {
974
+ const Sharp = await getSharp();
975
+ let sharp = Sharp(sourceData, {
443
976
  animated,
444
977
  ...options.sharpOptions
445
978
  });
@@ -449,15 +982,34 @@ function createIPX(userOptions) {
449
982
  name,
450
983
  args: arguments_
451
984
  })).filter((h) => h.handler).sort((a, b) => {
452
- const aKey = (a.handler.order || a.name || "").toString();
453
- const bKey = (b.handler.order || b.name || "").toString();
454
- return aKey.localeCompare(bKey);
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;
455
988
  });
456
- const handlerContext = { meta: imageMeta$1 };
989
+ const handlerContext = {
990
+ meta: imageMeta$1,
991
+ maxOutputDimension: options.maxOutputDimension
992
+ };
457
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
+ }
458
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
+ }
459
1011
  return {
460
- data: await sharp.toBuffer(),
1012
+ data: processedImage,
461
1013
  format,
462
1014
  meta: imageMeta$1
463
1015
  };
@@ -467,42 +1019,54 @@ function createIPX(userOptions) {
467
1019
  }
468
1020
  var import_etag = /* @__PURE__ */ __toESM(require_etag(), 1);
469
1021
  var import_accept_negotiator = require_accept_negotiator();
470
- function createIPXFetchHandler(ipx) {
471
- return createIPXHandler(ipx).fetch;
1022
+ function createIPXFetchHandler(ipx, opts) {
1023
+ return createIPXHandler(ipx, opts).fetch;
472
1024
  }
473
- function createIPXNodeHandler(ipx) {
1025
+ function createIPXNodeHandler(ipx, opts) {
474
1026
  const { toNodeHandler } = requireModule("srvx/node");
475
- return toNodeHandler(createIPXFetchHandler(ipx));
1027
+ return toNodeHandler(createIPXFetchHandler(ipx, opts));
476
1028
  }
477
1029
  function serveIPX(ipx, opts) {
478
1030
  const { serve } = requireModule("srvx");
479
- const fetch = createIPXFetchHandler(ipx);
1031
+ const { parseURL, ...serverOptions } = opts || {};
1032
+ const fetch = createIPXFetchHandler(ipx, { parseURL });
480
1033
  return serve({
481
- ...opts,
1034
+ ...serverOptions,
482
1035
  fetch
483
1036
  });
484
1037
  }
485
1038
  const MODIFIER_SEP = /[&,]/g;
486
1039
  const MODIFIER_VAL_SEP = /[:=_]/;
487
- 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;
488
1060
  return defineEventHandler(async (event) => {
489
- const [modifiersString = "", ...idSegments] = event.url.pathname.slice(1).split("/");
490
- const id = safeString(decode(idSegments.join("/")));
491
- if (!modifiersString) throw new HTTPError({
492
- statusCode: 400,
493
- statusText: "IPX_MISSING_MODIFIERS",
494
- message: `Modifiers are missing: ${id}`
495
- });
1061
+ const parsed = await parseURL(event.req.url) || {};
1062
+ const id = safeString(parsed.id);
496
1063
  if (!id || id === "/") throw new HTTPError({
497
1064
  statusCode: 400,
498
1065
  statusText: "IPX_MISSING_ID",
499
1066
  message: `Resource id is missing: ${event.path}`
500
1067
  });
501
1068
  const modifiers = Object.create(null);
502
- if (modifiersString !== "_") for (const p of modifiersString.split(MODIFIER_SEP)) {
503
- const [key, ...values] = p.split(MODIFIER_VAL_SEP);
504
- modifiers[safeString(key)] = values.map((v) => safeString(decode(v))).join("_");
505
- }
1069
+ for (const [key, value] of Object.entries(parsed.modifiers || {})) modifiers[safeString(key)] = safeString(value);
506
1070
  if ((modifiers.f || modifiers.format) === "auto") {
507
1071
  const acceptHeader = event.req.headers.get("accept") || "";
508
1072
  const animated = modifiers.animated ?? modifiers.a;
@@ -517,6 +1081,9 @@ function createIPXHandler(ipx) {
517
1081
  const img = ipx(id, modifiers);
518
1082
  const sourceMeta = await img.getSourceMeta();
519
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");
520
1087
  if (sourceMeta.mtime) {
521
1088
  sendResponseHeaderIfNotSet(event, "last-modified", sourceMeta.mtime.toUTCString());
522
1089
  const _ifModifiedSince = event.req.headers.get("if-modified-since");
@@ -525,13 +1092,22 @@ function createIPXHandler(ipx) {
525
1092
  return;
526
1093
  }
527
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
+ }
528
1103
  const { data, format } = await img.process();
529
- if (typeof sourceMeta.maxAge === "number") sendResponseHeaderIfNotSet(event, "cache-control", `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`);
530
- const etag = (0, import_etag.default)(data);
531
- sendResponseHeaderIfNotSet(event, "etag", etag);
532
- if (etag && event.req.headers.get("if-none-match") === etag) {
533
- event.res.status = 304;
534
- 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
+ }
535
1111
  }
536
1112
  if (format) sendResponseHeaderIfNotSet(event, "content-type", `image/${format}`);
537
1113
  return data;
@@ -540,6 +1116,25 @@ function createIPXHandler(ipx) {
540
1116
  function sendResponseHeaderIfNotSet(event, name, value) {
541
1117
  if (!event.res.headers.has(name)) event.res.headers.set(name, value);
542
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
+ }
543
1138
  function autoDetectFormat(acceptHeader, animated) {
544
1139
  if (animated) return (0, import_accept_negotiator.negotiate)(acceptHeader, ["image/webp", "image/gif"])?.split("/")[1] || "gif";
545
1140
  return (0, import_accept_negotiator.negotiate)(acceptHeader, [
@@ -552,33 +1147,225 @@ function autoDetectFormat(acceptHeader, animated) {
552
1147
  "image/gif"
553
1148
  ])?.split("/")[1] || "jpeg";
554
1149
  }
1150
+ function decode$1(input) {
1151
+ try {
1152
+ return decodeURIComponent(input);
1153
+ } catch {
1154
+ return input;
1155
+ }
1156
+ }
555
1157
  function safeString(input) {
556
- return JSON.stringify(input).replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
1158
+ return JSON.stringify(input ?? "").replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
557
1159
  }
558
1160
  const HTTP_RE = /^https?:\/\//;
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
+ }
559
1249
  function ipxHttpStorage(_options = {}) {
560
1250
  const allowAllDomains = _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false;
561
1251
  let _domains = _options.domains || getEnv("IPX_HTTP_DOMAINS") || [];
562
1252
  const defaultMaxAge = _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300;
563
1253
  const fetchOptions = _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {};
1254
+ const blockPrivateIPs = _options.blockPrivateIPs ?? getEnv("IPX_HTTP_BLOCK_PRIVATE_IPS") ?? false;
564
1255
  if (typeof _domains === "string") _domains = _domains.split(",").map((s) => s.trim());
565
1256
  const domains = new Set(_domains.map((d) => {
566
1257
  if (!HTTP_RE.test(d)) d = "http://" + d;
567
1258
  return new URL(d).hostname;
568
1259
  }).filter(Boolean));
569
- function validateId(id) {
570
- 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()) {
571
1275
  if (!url.hostname) throw new HTTPError({
572
1276
  statusCode: 403,
573
1277
  statusText: `IPX_MISSING_HOSTNAME`,
574
1278
  message: `Hostname is missing: ${id}`
575
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
+ });
576
1285
  if (!allowAllDomains && !domains.has(url.hostname)) throw new HTTPError({
577
1286
  statusCode: 403,
578
1287
  statusText: `IPX_FORBIDDEN_HOST`,
579
1288
  message: `Forbidden host: ${url.hostname}`
580
1289
  });
581
- 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
+ });
582
1369
  }
583
1370
  function parseResponse(response) {
584
1371
  let maxAge = defaultMaxAge;
@@ -600,12 +1387,9 @@ function ipxHttpStorage(_options = {}) {
600
1387
  return {
601
1388
  name: "ipx:http",
602
1389
  async getMeta(id) {
603
- const url = validateId(id);
1390
+ const url = await validateId(id);
604
1391
  try {
605
- const response = await fetch(url, {
606
- ...fetchOptions,
607
- method: "HEAD"
608
- });
1392
+ const response = await fetchURL(url, { method: "HEAD" });
609
1393
  if (!response.ok) return {};
610
1394
  const { maxAge, mtime } = parseResponse(response);
611
1395
  return {
@@ -617,8 +1401,7 @@ function ipxHttpStorage(_options = {}) {
617
1401
  }
618
1402
  },
619
1403
  async getData(id) {
620
- const url = validateId(id);
621
- const response = await fetch(url, fetchOptions);
1404
+ const response = await fetchURL(await validateId(id));
622
1405
  if (!response.ok) throw new HTTPError({
623
1406
  statusCode: response.status,
624
1407
  statusText: response.statusText,
@@ -628,33 +1411,66 @@ function ipxHttpStorage(_options = {}) {
628
1411
  }
629
1412
  };
630
1413
  }
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
+ ]);
631
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");
632
1424
  const dirs = resolveDirs(_options.dir);
633
- const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE");
634
- 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
+ };
635
1436
  const resolveFile = async (id) => {
636
- for (const dir of dirs) {
1437
+ for (const [index, dir] of dirs.entries()) {
637
1438
  const filePath = join(dir, id);
638
- if (!isValidPath(filePath) || !filePath.startsWith(dir + "/")) throw new HTTPError({
1439
+ if (!isValidPath(filePath) || !isInsideDir(filePath, dir)) throw new HTTPError({
639
1440
  statusCode: 403,
640
1441
  statusText: `IPX_FORBIDDEN_PATH`,
641
1442
  message: `Forbidden path: ${id}`
642
1443
  });
1444
+ let stats;
1445
+ let realPath;
643
1446
  try {
644
- const stats = await fs.stat(filePath);
645
- if (!stats.isFile()) continue;
646
- return {
647
- stats,
648
- read: () => fs.readFile(filePath)
649
- };
1447
+ stats = await fs.stat(filePath);
1448
+ if (stats.isFile() && !allowSymlinksOutsideDir) realPath = await fs.realpath(filePath);
650
1449
  } catch (error) {
651
- if (error.code === "ENOENT") continue;
1450
+ if (NOT_IN_THIS_DIR.has(error.code)) continue;
652
1451
  throw new HTTPError({
653
1452
  statusCode: 403,
654
1453
  statusText: `IPX_FORBIDDEN_FILE`,
655
1454
  message: `Cannot access file: ${id}`
656
1455
  });
657
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
+ };
658
1474
  }
659
1475
  throw new HTTPError({
660
1476
  statusCode: 404,
@@ -677,14 +1493,22 @@ function ipxFSStorage(_options = {}) {
677
1493
  }
678
1494
  };
679
1495
  }
680
- const isWindows = process.platform === "win32";
1496
+ const isWindows = globalThis.process?.platform === "win32";
681
1497
  function isValidPath(fp) {
682
1498
  if (isWindows) fp = fp.slice(parse(fp).root.length);
683
1499
  if (/["*:<>?|]/.test(fp)) return false;
684
1500
  return true;
685
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
+ }
686
1510
  function resolveDirs(dirs) {
687
1511
  if (!dirs || !Array.isArray(dirs)) return [resolve(dirs || getEnv("IPX_FS_DIR") || ".")];
688
1512
  return dirs.map((dirs) => resolve(dirs));
689
1513
  }
690
- export { createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, serveIPX };
1514
+ export { createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, parseIPXURL, serveIPX };