ipx 0.9.2 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -52,27 +52,29 @@ Resize to `200x200px` using `embed` method and change format to `webp`:
52
52
 
53
53
  ### Modifiers
54
54
 
55
- | Property | Docs | Example | Comments |
56
- | ----------- | :-------------------------------------------------------------- | :------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
- | width / w | \_ | `http://localhost:3000/width_200/buffalo.png` |
58
- | height / h | \_ | `http://localhost:3000/height_200/buffalo.png` |
59
- | resize / s | \_ | `http://localhost:3000/s_200x200/buffalo.png` |
60
- | trim | [Docs](https://sharp.pixelplumbing.com/api-resize#trim) | `http://localhost:3000/trim_100/buffalo.png` |
61
- | format | [Docs](https://sharp.pixelplumbing.com/api-output#toformat) | `http://localhost:3000/format_webp/buffalo.png` | Supported format: `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif`, `heif` |
62
- | quality / q | \_ | `http://localhost:3000/quality_50/buffalo.png` | Accepted values: 0 to 100 |
63
- | rotate | [Docs](https://sharp.pixelplumbing.com/api-operation#rotate) | `http://localhost:3000/rotate_45/buffalo.png` |
64
- | enlarge | \_ | `http://localhost:3000/enlarge,s_2000x2000/buffalo.png` | Allow the image to be upscaled. By default the returned image will never be larger than the source in any dimension, while preserving the requested aspect ratio. |
65
- | flip | [Docs](https://sharp.pixelplumbing.com/api-operation#flip) | `http://localhost:3000/flip/buffalo.png` |
66
- | flop | [Docs](https://sharp.pixelplumbing.com/api-operation#flop) | `http://localhost:3000/flop/buffalo.png` |
67
- | sharpen | [Docs](https://sharp.pixelplumbing.com/api-operation#sharpen) | `http://localhost:3000/sharpen_30/buffalo.png` |
68
- | median | [Docs](https://sharp.pixelplumbing.com/api-operation#median) | `http://localhost:3000/median_10/buffalo.png` |
69
- | gamma | [Docs](https://sharp.pixelplumbing.com/api-operation#gamma) | `http://localhost:3000/gamma_3/buffalo.png` |
70
- | negate | [Docs](https://sharp.pixelplumbing.com/api-operation#negate) | `http://localhost:3000/negate/buffalo.png` |
71
- | normalize | [Docs](https://sharp.pixelplumbing.com/api-operation#normalize) | `http://localhost:3000/normalize/buffalo.png` |
72
- | threshold | [Docs](https://sharp.pixelplumbing.com/api-operation#threshold) | `http://localhost:3000/threshold_10/buffalo.png` |
73
- | tint | [Docs](https://sharp.pixelplumbing.com/api-colour#tint) | `http://localhost:3000/tint_1098123/buffalo.png` |
74
- | grayscale | [Docs](https://sharp.pixelplumbing.com/api-colour#grayscale) | `http://localhost:3000/grayscale/buffalo.png` |
75
- | animated | - | `http://localhost:3000/animated/buffalo.gif` | Experimental |
55
+ | Property | Docs | Example | Comments |
56
+ | --------------- | :-------------------------------------------------------------- | :---------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | width / w | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `http://localhost:3000/width_200/buffalo.png` |
58
+ | height / h | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `http://localhost:3000/height_200/buffalo.png` |
59
+ | resize / s | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `http://localhost:3000/s_200x200/buffalo.png` |
60
+ | fit | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `http://localhost:3000/s_200x200,fit_outside/buffalo.png` | Sets `fit` option for `resize`.
61
+ | position / pos | [Docs](https://sharp.pixelplumbing.com/api-resize#resize) | `http://localhost:3000/s_200x200,pos_top/buffalo.png` | Sets `position` option for `resize`.
62
+ | trim | [Docs](https://sharp.pixelplumbing.com/api-resize#trim) | `http://localhost:3000/trim_100/buffalo.png` |
63
+ | format | [Docs](https://sharp.pixelplumbing.com/api-output#toformat) | `http://localhost:3000/format_webp/buffalo.png` | Supported format: `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif`, `heif` |
64
+ | quality / q | \_ | `http://localhost:3000/quality_50/buffalo.png` | Accepted values: 0 to 100 |
65
+ | rotate | [Docs](https://sharp.pixelplumbing.com/api-operation#rotate) | `http://localhost:3000/rotate_45/buffalo.png` |
66
+ | enlarge | \_ | `http://localhost:3000/enlarge,s_2000x2000/buffalo.png` | Allow the image to be upscaled. By default the returned image will never be larger than the source in any dimension, while preserving the requested aspect ratio. |
67
+ | flip | [Docs](https://sharp.pixelplumbing.com/api-operation#flip) | `http://localhost:3000/flip/buffalo.png` |
68
+ | flop | [Docs](https://sharp.pixelplumbing.com/api-operation#flop) | `http://localhost:3000/flop/buffalo.png` |
69
+ | sharpen | [Docs](https://sharp.pixelplumbing.com/api-operation#sharpen) | `http://localhost:3000/sharpen_30/buffalo.png` |
70
+ | median | [Docs](https://sharp.pixelplumbing.com/api-operation#median) | `http://localhost:3000/median_10/buffalo.png` |
71
+ | gamma | [Docs](https://sharp.pixelplumbing.com/api-operation#gamma) | `http://localhost:3000/gamma_3/buffalo.png` |
72
+ | negate | [Docs](https://sharp.pixelplumbing.com/api-operation#negate) | `http://localhost:3000/negate/buffalo.png` |
73
+ | normalize | [Docs](https://sharp.pixelplumbing.com/api-operation#normalize) | `http://localhost:3000/normalize/buffalo.png` |
74
+ | threshold | [Docs](https://sharp.pixelplumbing.com/api-operation#threshold) | `http://localhost:3000/threshold_10/buffalo.png` |
75
+ | tint | [Docs](https://sharp.pixelplumbing.com/api-colour#tint) | `http://localhost:3000/tint_1098123/buffalo.png` |
76
+ | grayscale | [Docs](https://sharp.pixelplumbing.com/api-colour#grayscale) | `http://localhost:3000/grayscale/buffalo.png` |
77
+ | animated | - | `http://localhost:3000/animated/buffalo.gif` | Experimental |
76
78
 
77
79
  ### Config
78
80
 
@@ -5,7 +5,6 @@ const imageMeta = require('image-meta');
5
5
  const ufo = require('ufo');
6
6
  const fs = require('fs');
7
7
  const pathe = require('pathe');
8
- const isValidPath = require('is-valid-path');
9
8
  const http = require('http');
10
9
  const https = require('https');
11
10
  const ohmyfetch = require('ohmyfetch');
@@ -16,7 +15,6 @@ const xss = require('xss');
16
15
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
17
16
 
18
17
  const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
19
- const isValidPath__default = /*#__PURE__*/_interopDefaultLegacy(isValidPath);
20
18
  const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
21
19
  const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
22
20
  const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
@@ -27,6 +25,7 @@ const Handlers = {
27
25
  __proto__: null,
28
26
  get quality () { return quality; },
29
27
  get fit () { return fit; },
28
+ get position () { return position; },
30
29
  get background () { return background; },
31
30
  get enlarge () { return enlarge; },
32
31
  get width () { return width; },
@@ -54,7 +53,8 @@ const Handlers = {
54
53
  get b () { return b; },
55
54
  get w () { return w; },
56
55
  get h () { return h; },
57
- get s () { return s; }
56
+ get s () { return s; },
57
+ get pos () { return pos; }
58
58
  };
59
59
 
60
60
  function getEnv(name, defaultValue) {
@@ -72,9 +72,9 @@ function cachedPromise(fn) {
72
72
  }
73
73
  class IPXError extends Error {
74
74
  }
75
- function createError(message, statusCode) {
76
- const err = new IPXError(message);
77
- err.statusMessage = "IPX: " + message;
75
+ function createError(statusMessage, statusCode, trace) {
76
+ const err = new IPXError(statusMessage + (trace ? ` (${trace})` : ""));
77
+ err.statusMessage = "IPX: " + statusMessage;
78
78
  err.statusCode = statusCode;
79
79
  return err;
80
80
  }
@@ -83,29 +83,35 @@ const createFilesystemSource = (options) => {
83
83
  const rootDir = pathe.resolve(options.dir);
84
84
  return async (id) => {
85
85
  const fsPath = pathe.resolve(pathe.join(rootDir, id));
86
- if (!isValidPath__default(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
87
- throw createError("Forbidden path:" + id, 403);
86
+ if (!isValidPath(fsPath) || !fsPath.startsWith(rootDir)) {
87
+ throw createError("Forbidden path", 403, id);
88
88
  }
89
89
  let stats;
90
90
  try {
91
91
  stats = await fs.promises.stat(fsPath);
92
92
  } catch (err) {
93
93
  if (err.code === "ENOENT") {
94
- throw createError("File not found: " + fsPath, 404);
94
+ throw createError("File not found", 404, fsPath);
95
95
  } else {
96
- throw createError("File access error for " + fsPath + ":" + err.code, 403);
96
+ throw createError("File access error " + err.code, 403, fsPath);
97
97
  }
98
98
  }
99
99
  if (!stats.isFile()) {
100
- throw createError("Path should be a file: " + fsPath, 400);
100
+ throw createError("Path should be a file", 400, fsPath);
101
101
  }
102
102
  return {
103
103
  mtime: stats.mtime,
104
- maxAge: options.maxAge || 300,
104
+ maxAge: options.maxAge,
105
105
  getData: cachedPromise(() => fs.promises.readFile(fsPath))
106
106
  };
107
107
  };
108
108
  };
109
+ function isValidPath(fp) {
110
+ if (/[<>:"|?*]/.test(fp)) {
111
+ return false;
112
+ }
113
+ return true;
114
+ }
109
115
 
110
116
  const createHTTPSource = (options) => {
111
117
  const httpsAgent = new https__default.Agent({ keepAlive: true });
@@ -118,18 +124,19 @@ const createHTTPSource = (options) => {
118
124
  return async (id, reqOptions) => {
119
125
  const url = new URL(id);
120
126
  if (!url.hostname) {
121
- throw createError("Hostname is missing: " + id, 403);
127
+ throw createError("Hostname is missing", 403, id);
122
128
  }
123
129
  if (!reqOptions?.bypassDomain && !hosts.find((host) => url.hostname === host)) {
124
- throw createError("Forbidden host: " + url.hostname, 403);
130
+ throw createError("Forbidden host", 403, url.hostname);
125
131
  }
126
132
  const response = await ohmyfetch.fetch(id, {
127
- agent: id.startsWith("https") ? httpsAgent : httpAgent
133
+ agent: id.startsWith("https") ? httpsAgent : httpAgent,
134
+ ...options.fetchOptions
128
135
  });
129
136
  if (!response.ok) {
130
- throw createError(response.statusText || "fetch error", response.status || 500);
137
+ throw createError("Fetch error", response.status || 500, response.statusText);
131
138
  }
132
- let maxAge = options.maxAge || 300;
139
+ let maxAge = options.maxAge;
133
140
  const _cacheControl = response.headers.get("cache-control");
134
141
  if (_cacheControl) {
135
142
  const m = _cacheControl.match(/max-age=(\d+)/);
@@ -145,7 +152,7 @@ const createHTTPSource = (options) => {
145
152
  return {
146
153
  mtime,
147
154
  maxAge,
148
- getData: cachedPromise(() => response.buffer())
155
+ getData: cachedPromise(() => response.arrayBuffer().then((ab) => Buffer.from(ab)))
149
156
  };
150
157
  };
151
158
  };
@@ -192,6 +199,13 @@ const fit = {
192
199
  context.fit = fit2;
193
200
  }
194
201
  };
202
+ const position = {
203
+ args: [VArg],
204
+ order: -1,
205
+ apply: (context, _pipe, position2) => {
206
+ context.position = position2;
207
+ }
208
+ };
195
209
  const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
196
210
  const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
197
211
  const background = {
@@ -227,6 +241,12 @@ const resize = {
227
241
  args: [VArg, VArg, VArg],
228
242
  apply: (context, pipe, size) => {
229
243
  let [width2, height2] = String(size).split("x").map((v) => Number(v));
244
+ if (!width2) {
245
+ return;
246
+ }
247
+ if (!height2) {
248
+ height2 = width2;
249
+ }
230
250
  if (!context.enlarge) {
231
251
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
232
252
  width2 = clamped.width;
@@ -234,6 +254,7 @@ const resize = {
234
254
  }
235
255
  return pipe.resize(width2, height2, {
236
256
  fit: context.fit,
257
+ position: context.position,
237
258
  background: context.background
238
259
  });
239
260
  }
@@ -366,13 +387,16 @@ const b = background;
366
387
  const w = width;
367
388
  const h = height;
368
389
  const s = resize;
390
+ const pos = position;
369
391
 
370
- const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
392
+ const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff", "gif"];
371
393
  function createIPX(userOptions) {
372
394
  const defaults = {
373
395
  dir: getEnv("IPX_DIR", "."),
374
396
  domains: getEnv("IPX_DOMAINS", []),
375
397
  alias: getEnv("IPX_ALIAS", {}),
398
+ fetchOptions: getEnv("IPX_FETCH_OPTIONS", {}),
399
+ maxAge: getEnv("IPX_MAX_AGE", 300),
376
400
  sharp: {}
377
401
  };
378
402
  const options = defu__default(userOptions, defaults);
@@ -382,12 +406,15 @@ function createIPX(userOptions) {
382
406
  };
383
407
  if (options.dir) {
384
408
  ctx.sources.filesystem = createFilesystemSource({
385
- dir: options.dir
409
+ dir: options.dir,
410
+ maxAge: options.maxAge
386
411
  });
387
412
  }
388
413
  if (options.domains) {
389
414
  ctx.sources.http = createHTTPSource({
390
- domains: options.domains
415
+ domains: options.domains,
416
+ fetchOptions: options.fetchOptions,
417
+ maxAge: options.maxAge
391
418
  });
392
419
  }
393
420
  return function ipx(id, modifiers = {}, reqOptions = {}) {
@@ -403,7 +430,7 @@ function createIPX(userOptions) {
403
430
  const getSrc = cachedPromise(() => {
404
431
  const source = ufo.hasProtocol(id) ? "http" : "filesystem";
405
432
  if (!ctx.sources[source]) {
406
- throw createError("Unknown source: " + source, 400);
433
+ throw createError("Unknown source", 400, source);
407
434
  }
408
435
  return ctx.sources[source](id, reqOptions);
409
436
  });
@@ -423,10 +450,7 @@ function createIPX(userOptions) {
423
450
  meta
424
451
  };
425
452
  }
426
- const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
427
- if (animated) {
428
- format = "webp";
429
- }
453
+ const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
430
454
  const Sharp = await import('sharp').then((r) => r.default || r);
431
455
  let sharp = Sharp(data, { animated });
432
456
  Object.assign(sharp.options, options.sharp);
@@ -459,6 +483,8 @@ function createIPX(userOptions) {
459
483
  };
460
484
  }
461
485
 
486
+ const MODIFIER_SEP = /[,&]/g;
487
+ const MODIFIER_VAL_SEP = /[_=:]/g;
462
488
  async function _handleRequest(req, ipx) {
463
489
  const res = {
464
490
  statusCode: 200,
@@ -466,19 +492,19 @@ async function _handleRequest(req, ipx) {
466
492
  headers: {},
467
493
  body: ""
468
494
  };
469
- const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
470
- const id = ufo.decode(idSegments.join("/"));
495
+ const [modifiersStr = "", ...idSegments] = req.url.substring(1).split("/");
496
+ const id = safeString(ufo.decode(idSegments.join("/")));
471
497
  if (!modifiersStr) {
472
- throw createError("Modifiers is missing in path: " + req.url, 400);
498
+ throw createError("Modifiers are missing", 400, req.url);
473
499
  }
474
500
  if (!id || id === "/") {
475
- throw createError("Resource id is missing: " + req.url, 400);
501
+ throw createError("Resource id is missing", 400, req.url);
476
502
  }
477
- const modifiers = Object.create(null);
503
+ const modifiers = /* @__PURE__ */ Object.create(null);
478
504
  if (modifiersStr !== "_") {
479
- for (const p of modifiersStr.split(",")) {
480
- const [key, value = ""] = p.split("_");
481
- modifiers[key] = ufo.decode(value);
505
+ for (const p of modifiersStr.split(MODIFIER_SEP)) {
506
+ const [key, value = ""] = p.split(MODIFIER_VAL_SEP);
507
+ modifiers[safeString(key)] = safeString(ufo.decode(value));
482
508
  }
483
509
  }
484
510
  const img = ipx(id, modifiers, req.options);
@@ -492,7 +518,7 @@ async function _handleRequest(req, ipx) {
492
518
  }
493
519
  res.headers["Last-Modified"] = +src.mtime + "";
494
520
  }
495
- if (src.maxAge !== void 0) {
521
+ if (typeof src.maxAge === "number") {
496
522
  res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
497
523
  }
498
524
  const { data, format } = await img.data();
@@ -506,21 +532,21 @@ async function _handleRequest(req, ipx) {
506
532
  res.headers["Content-Type"] = `image/${format}`;
507
533
  }
508
534
  res.body = data;
509
- return res;
535
+ return sanetizeReponse(res);
510
536
  }
511
537
  function handleRequest(req, ipx) {
512
538
  return _handleRequest(req, ipx).catch((err) => {
513
539
  const statusCode = parseInt(err.statusCode) || 500;
514
- const statusMessage = err.statusMessage ? xss__default(err.statusMessage) : `IPX Error (${statusCode})`;
540
+ const statusMessage = err.statusMessage ? err.statusMessage : `IPX Error (${statusCode})`;
515
541
  if (process.env.NODE_ENV !== "production" && statusCode === 500) {
516
542
  console.error(err);
517
543
  }
518
- return {
544
+ return sanetizeReponse({
519
545
  statusCode,
520
546
  statusMessage,
521
- body: statusMessage,
547
+ body: "IPX Error: " + err,
522
548
  headers: {}
523
- };
549
+ });
524
550
  });
525
551
  }
526
552
  function createIPXMiddleware(ipx) {
@@ -535,6 +561,24 @@ function createIPXMiddleware(ipx) {
535
561
  });
536
562
  };
537
563
  }
564
+ function sanetizeReponse(res) {
565
+ return {
566
+ statusCode: res.statusCode || 200,
567
+ statusMessage: res.statusMessage ? safeString(res.statusMessage) : "OK",
568
+ headers: safeStringObject(res.headers || {}),
569
+ body: typeof res.body === "string" ? xss__default(safeString(res.body)) : res.body || ""
570
+ };
571
+ }
572
+ function safeString(input) {
573
+ return JSON.stringify(input).replace(/^"|"$/g, "");
574
+ }
575
+ function safeStringObject(input) {
576
+ const dst = {};
577
+ for (const key in input) {
578
+ dst[key] = safeString(input[key]);
579
+ }
580
+ return dst;
581
+ }
538
582
 
539
583
  exports.createIPX = createIPX;
540
584
  exports.createIPXMiddleware = createIPXMiddleware;
@@ -3,7 +3,6 @@ import { imageMeta } from 'image-meta';
3
3
  import { parseURL, withLeadingSlash, hasProtocol, joinURL, decode } from 'ufo';
4
4
  import { promises } from 'fs';
5
5
  import { resolve, join } from 'pathe';
6
- import isValidPath from 'is-valid-path';
7
6
  import http from 'http';
8
7
  import https from 'https';
9
8
  import { fetch } from 'ohmyfetch';
@@ -15,6 +14,7 @@ const Handlers = {
15
14
  __proto__: null,
16
15
  get quality () { return quality; },
17
16
  get fit () { return fit; },
17
+ get position () { return position; },
18
18
  get background () { return background; },
19
19
  get enlarge () { return enlarge; },
20
20
  get width () { return width; },
@@ -42,7 +42,8 @@ const Handlers = {
42
42
  get b () { return b; },
43
43
  get w () { return w; },
44
44
  get h () { return h; },
45
- get s () { return s; }
45
+ get s () { return s; },
46
+ get pos () { return pos; }
46
47
  };
47
48
 
48
49
  function getEnv(name, defaultValue) {
@@ -60,9 +61,9 @@ function cachedPromise(fn) {
60
61
  }
61
62
  class IPXError extends Error {
62
63
  }
63
- function createError(message, statusCode) {
64
- const err = new IPXError(message);
65
- err.statusMessage = "IPX: " + message;
64
+ function createError(statusMessage, statusCode, trace) {
65
+ const err = new IPXError(statusMessage + (trace ? ` (${trace})` : ""));
66
+ err.statusMessage = "IPX: " + statusMessage;
66
67
  err.statusCode = statusCode;
67
68
  return err;
68
69
  }
@@ -71,29 +72,35 @@ const createFilesystemSource = (options) => {
71
72
  const rootDir = resolve(options.dir);
72
73
  return async (id) => {
73
74
  const fsPath = resolve(join(rootDir, id));
74
- if (!isValidPath(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
75
- throw createError("Forbidden path:" + id, 403);
75
+ if (!isValidPath(fsPath) || !fsPath.startsWith(rootDir)) {
76
+ throw createError("Forbidden path", 403, id);
76
77
  }
77
78
  let stats;
78
79
  try {
79
80
  stats = await promises.stat(fsPath);
80
81
  } catch (err) {
81
82
  if (err.code === "ENOENT") {
82
- throw createError("File not found: " + fsPath, 404);
83
+ throw createError("File not found", 404, fsPath);
83
84
  } else {
84
- throw createError("File access error for " + fsPath + ":" + err.code, 403);
85
+ throw createError("File access error " + err.code, 403, fsPath);
85
86
  }
86
87
  }
87
88
  if (!stats.isFile()) {
88
- throw createError("Path should be a file: " + fsPath, 400);
89
+ throw createError("Path should be a file", 400, fsPath);
89
90
  }
90
91
  return {
91
92
  mtime: stats.mtime,
92
- maxAge: options.maxAge || 300,
93
+ maxAge: options.maxAge,
93
94
  getData: cachedPromise(() => promises.readFile(fsPath))
94
95
  };
95
96
  };
96
97
  };
98
+ function isValidPath(fp) {
99
+ if (/[<>:"|?*]/.test(fp)) {
100
+ return false;
101
+ }
102
+ return true;
103
+ }
97
104
 
98
105
  const createHTTPSource = (options) => {
99
106
  const httpsAgent = new https.Agent({ keepAlive: true });
@@ -106,18 +113,19 @@ const createHTTPSource = (options) => {
106
113
  return async (id, reqOptions) => {
107
114
  const url = new URL(id);
108
115
  if (!url.hostname) {
109
- throw createError("Hostname is missing: " + id, 403);
116
+ throw createError("Hostname is missing", 403, id);
110
117
  }
111
118
  if (!reqOptions?.bypassDomain && !hosts.find((host) => url.hostname === host)) {
112
- throw createError("Forbidden host: " + url.hostname, 403);
119
+ throw createError("Forbidden host", 403, url.hostname);
113
120
  }
114
121
  const response = await fetch(id, {
115
- agent: id.startsWith("https") ? httpsAgent : httpAgent
122
+ agent: id.startsWith("https") ? httpsAgent : httpAgent,
123
+ ...options.fetchOptions
116
124
  });
117
125
  if (!response.ok) {
118
- throw createError(response.statusText || "fetch error", response.status || 500);
126
+ throw createError("Fetch error", response.status || 500, response.statusText);
119
127
  }
120
- let maxAge = options.maxAge || 300;
128
+ let maxAge = options.maxAge;
121
129
  const _cacheControl = response.headers.get("cache-control");
122
130
  if (_cacheControl) {
123
131
  const m = _cacheControl.match(/max-age=(\d+)/);
@@ -133,7 +141,7 @@ const createHTTPSource = (options) => {
133
141
  return {
134
142
  mtime,
135
143
  maxAge,
136
- getData: cachedPromise(() => response.buffer())
144
+ getData: cachedPromise(() => response.arrayBuffer().then((ab) => Buffer.from(ab)))
137
145
  };
138
146
  };
139
147
  };
@@ -180,6 +188,13 @@ const fit = {
180
188
  context.fit = fit2;
181
189
  }
182
190
  };
191
+ const position = {
192
+ args: [VArg],
193
+ order: -1,
194
+ apply: (context, _pipe, position2) => {
195
+ context.position = position2;
196
+ }
197
+ };
183
198
  const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
184
199
  const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
185
200
  const background = {
@@ -215,6 +230,12 @@ const resize = {
215
230
  args: [VArg, VArg, VArg],
216
231
  apply: (context, pipe, size) => {
217
232
  let [width2, height2] = String(size).split("x").map((v) => Number(v));
233
+ if (!width2) {
234
+ return;
235
+ }
236
+ if (!height2) {
237
+ height2 = width2;
238
+ }
218
239
  if (!context.enlarge) {
219
240
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
220
241
  width2 = clamped.width;
@@ -222,6 +243,7 @@ const resize = {
222
243
  }
223
244
  return pipe.resize(width2, height2, {
224
245
  fit: context.fit,
246
+ position: context.position,
225
247
  background: context.background
226
248
  });
227
249
  }
@@ -354,13 +376,16 @@ const b = background;
354
376
  const w = width;
355
377
  const h = height;
356
378
  const s = resize;
379
+ const pos = position;
357
380
 
358
- const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
381
+ const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff", "gif"];
359
382
  function createIPX(userOptions) {
360
383
  const defaults = {
361
384
  dir: getEnv("IPX_DIR", "."),
362
385
  domains: getEnv("IPX_DOMAINS", []),
363
386
  alias: getEnv("IPX_ALIAS", {}),
387
+ fetchOptions: getEnv("IPX_FETCH_OPTIONS", {}),
388
+ maxAge: getEnv("IPX_MAX_AGE", 300),
364
389
  sharp: {}
365
390
  };
366
391
  const options = defu(userOptions, defaults);
@@ -370,12 +395,15 @@ function createIPX(userOptions) {
370
395
  };
371
396
  if (options.dir) {
372
397
  ctx.sources.filesystem = createFilesystemSource({
373
- dir: options.dir
398
+ dir: options.dir,
399
+ maxAge: options.maxAge
374
400
  });
375
401
  }
376
402
  if (options.domains) {
377
403
  ctx.sources.http = createHTTPSource({
378
- domains: options.domains
404
+ domains: options.domains,
405
+ fetchOptions: options.fetchOptions,
406
+ maxAge: options.maxAge
379
407
  });
380
408
  }
381
409
  return function ipx(id, modifiers = {}, reqOptions = {}) {
@@ -391,7 +419,7 @@ function createIPX(userOptions) {
391
419
  const getSrc = cachedPromise(() => {
392
420
  const source = hasProtocol(id) ? "http" : "filesystem";
393
421
  if (!ctx.sources[source]) {
394
- throw createError("Unknown source: " + source, 400);
422
+ throw createError("Unknown source", 400, source);
395
423
  }
396
424
  return ctx.sources[source](id, reqOptions);
397
425
  });
@@ -411,10 +439,7 @@ function createIPX(userOptions) {
411
439
  meta
412
440
  };
413
441
  }
414
- const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
415
- if (animated) {
416
- format = "webp";
417
- }
442
+ const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
418
443
  const Sharp = await import('sharp').then((r) => r.default || r);
419
444
  let sharp = Sharp(data, { animated });
420
445
  Object.assign(sharp.options, options.sharp);
@@ -447,6 +472,8 @@ function createIPX(userOptions) {
447
472
  };
448
473
  }
449
474
 
475
+ const MODIFIER_SEP = /[,&]/g;
476
+ const MODIFIER_VAL_SEP = /[_=:]/g;
450
477
  async function _handleRequest(req, ipx) {
451
478
  const res = {
452
479
  statusCode: 200,
@@ -454,19 +481,19 @@ async function _handleRequest(req, ipx) {
454
481
  headers: {},
455
482
  body: ""
456
483
  };
457
- const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
458
- const id = decode(idSegments.join("/"));
484
+ const [modifiersStr = "", ...idSegments] = req.url.substring(1).split("/");
485
+ const id = safeString(decode(idSegments.join("/")));
459
486
  if (!modifiersStr) {
460
- throw createError("Modifiers is missing in path: " + req.url, 400);
487
+ throw createError("Modifiers are missing", 400, req.url);
461
488
  }
462
489
  if (!id || id === "/") {
463
- throw createError("Resource id is missing: " + req.url, 400);
490
+ throw createError("Resource id is missing", 400, req.url);
464
491
  }
465
- const modifiers = Object.create(null);
492
+ const modifiers = /* @__PURE__ */ Object.create(null);
466
493
  if (modifiersStr !== "_") {
467
- for (const p of modifiersStr.split(",")) {
468
- const [key, value = ""] = p.split("_");
469
- modifiers[key] = decode(value);
494
+ for (const p of modifiersStr.split(MODIFIER_SEP)) {
495
+ const [key, value = ""] = p.split(MODIFIER_VAL_SEP);
496
+ modifiers[safeString(key)] = safeString(decode(value));
470
497
  }
471
498
  }
472
499
  const img = ipx(id, modifiers, req.options);
@@ -480,7 +507,7 @@ async function _handleRequest(req, ipx) {
480
507
  }
481
508
  res.headers["Last-Modified"] = +src.mtime + "";
482
509
  }
483
- if (src.maxAge !== void 0) {
510
+ if (typeof src.maxAge === "number") {
484
511
  res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
485
512
  }
486
513
  const { data, format } = await img.data();
@@ -494,21 +521,21 @@ async function _handleRequest(req, ipx) {
494
521
  res.headers["Content-Type"] = `image/${format}`;
495
522
  }
496
523
  res.body = data;
497
- return res;
524
+ return sanetizeReponse(res);
498
525
  }
499
526
  function handleRequest(req, ipx) {
500
527
  return _handleRequest(req, ipx).catch((err) => {
501
528
  const statusCode = parseInt(err.statusCode) || 500;
502
- const statusMessage = err.statusMessage ? xss(err.statusMessage) : `IPX Error (${statusCode})`;
529
+ const statusMessage = err.statusMessage ? err.statusMessage : `IPX Error (${statusCode})`;
503
530
  if (process.env.NODE_ENV !== "production" && statusCode === 500) {
504
531
  console.error(err);
505
532
  }
506
- return {
533
+ return sanetizeReponse({
507
534
  statusCode,
508
535
  statusMessage,
509
- body: statusMessage,
536
+ body: "IPX Error: " + err,
510
537
  headers: {}
511
- };
538
+ });
512
539
  });
513
540
  }
514
541
  function createIPXMiddleware(ipx) {
@@ -523,5 +550,23 @@ function createIPXMiddleware(ipx) {
523
550
  });
524
551
  };
525
552
  }
553
+ function sanetizeReponse(res) {
554
+ return {
555
+ statusCode: res.statusCode || 200,
556
+ statusMessage: res.statusMessage ? safeString(res.statusMessage) : "OK",
557
+ headers: safeStringObject(res.headers || {}),
558
+ body: typeof res.body === "string" ? xss(safeString(res.body)) : res.body || ""
559
+ };
560
+ }
561
+ function safeString(input) {
562
+ return JSON.stringify(input).replace(/^"|"$/g, "");
563
+ }
564
+ function safeStringObject(input) {
565
+ const dst = {};
566
+ for (const key in input) {
567
+ dst[key] = safeString(input[key]);
568
+ }
569
+ return dst;
570
+ }
526
571
 
527
572
  export { createIPXMiddleware as a, createIPX as c, handleRequest as h };
package/dist/cli.cjs CHANGED
@@ -8,7 +8,6 @@ require('image-meta');
8
8
  require('ufo');
9
9
  require('fs');
10
10
  require('pathe');
11
- require('is-valid-path');
12
11
  require('http');
13
12
  require('https');
14
13
  require('ohmyfetch');
package/dist/cli.mjs CHANGED
@@ -6,7 +6,6 @@ import 'image-meta';
6
6
  import 'ufo';
7
7
  import 'fs';
8
8
  import 'pathe';
9
- import 'is-valid-path';
10
9
  import 'http';
11
10
  import 'https';
12
11
  import 'ohmyfetch';
package/dist/index.cjs CHANGED
@@ -8,7 +8,6 @@ require('image-meta');
8
8
  require('ufo');
9
9
  require('fs');
10
10
  require('pathe');
11
- require('is-valid-path');
12
11
  require('http');
13
12
  require('https');
14
13
  require('ohmyfetch');
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ interface SourceData {
6
6
  getData: () => Promise<Buffer>;
7
7
  }
8
8
  declare type Source = (src: string, reqOptions?: any) => Promise<SourceData>;
9
- declare type SourceFactory = (options?: any) => Source;
9
+ declare type SourceFactory<T = Record<string, any>> = (options: T) => Source;
10
10
 
11
11
  interface ImageMeta {
12
12
  width: number;
@@ -27,8 +27,10 @@ declare type IPX = (id: string, modifiers?: Record<string, string>, reqOptions?:
27
27
  };
28
28
  interface IPXOptions {
29
29
  dir?: false | string;
30
+ maxAge?: number;
30
31
  domains?: false | string[];
31
32
  alias: Record<string, string>;
33
+ fetchOptions: RequestInit;
32
34
  sharp?: {
33
35
  [key: string]: any;
34
36
  };
package/dist/index.mjs CHANGED
@@ -4,7 +4,6 @@ import 'image-meta';
4
4
  import 'ufo';
5
5
  import 'fs';
6
6
  import 'pathe';
7
- import 'is-valid-path';
8
7
  import 'http';
9
8
  import 'https';
10
9
  import 'ohmyfetch';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ipx",
3
- "version": "0.9.2",
3
+ "version": "0.9.6",
4
4
  "repository": "unjs/ipx",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -9,51 +9,50 @@
9
9
  "import": "./dist/index.mjs"
10
10
  }
11
11
  },
12
- "main": "dist/index.cjs",
13
- "module": "dist/index.mjs",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.mjs",
14
14
  "types": "./dist/index.d.ts",
15
15
  "bin": "./bin/ipx.mjs",
16
16
  "files": [
17
17
  "dist",
18
18
  "bin"
19
19
  ],
20
- "scripts": {
21
- "build": "unbuild",
22
- "dev": "nodemon",
23
- "lint": "eslint --ext .ts .",
24
- "prepack": "yarn build",
25
- "release": "yarn test && standard-version && git push --follow-tags && npm publish",
26
- "start": "node bin/ipx.js",
27
- "test": "yarn lint && jest"
28
- },
29
20
  "dependencies": {
30
21
  "consola": "^2.15.3",
31
- "defu": "^5.0.0",
32
- "destr": "^1.1.0",
22
+ "defu": "^6.0.0",
23
+ "destr": "^1.1.1",
33
24
  "etag": "^1.8.1",
34
25
  "image-meta": "^0.1.1",
35
- "is-valid-path": "^0.1.1",
36
- "listhen": "^0.2.5",
37
- "ohmyfetch": "^0.4.2",
38
- "pathe": "^0.2.0",
39
- "sharp": "^0.29.0",
40
- "ufo": "^0.7.9",
41
- "xss": "^1.0.10"
26
+ "listhen": "^0.2.13",
27
+ "ohmyfetch": "^0.4.18",
28
+ "pathe": "^0.3.0",
29
+ "sharp": "^0.30.6",
30
+ "ufo": "^0.8.4",
31
+ "xss": "^1.0.13"
42
32
  },
43
33
  "devDependencies": {
44
34
  "@nuxtjs/eslint-config-typescript": "latest",
45
35
  "@types/etag": "latest",
46
36
  "@types/is-valid-path": "latest",
47
- "@types/jest": "latest",
48
37
  "@types/node-fetch": "latest",
49
38
  "@types/sharp": "latest",
39
+ "c8": "latest",
50
40
  "eslint": "latest",
51
- "jest": "latest",
52
41
  "jiti": "latest",
53
42
  "nodemon": "latest",
43
+ "serve-handler": "^6.1.3",
54
44
  "standard-version": "latest",
55
- "ts-jest": "latest",
56
45
  "typescript": "latest",
57
- "unbuild": "latest"
46
+ "unbuild": "latest",
47
+ "vitest": "latest"
48
+ },
49
+ "packageManager": "pnpm@7.3.0",
50
+ "scripts": {
51
+ "build": "unbuild",
52
+ "dev": "nodemon",
53
+ "lint": "eslint --ext .ts .",
54
+ "release": "pnpm test && standard-version && git push --follow-tags && pnpm publish",
55
+ "start": "node bin/ipx.js",
56
+ "test": "pnpm lint && vitest run --coverage"
58
57
  }
59
- }
58
+ }