ipx 0.9.3 → 0.9.7

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
@@ -0,0 +1,589 @@
1
+ 'use strict';
2
+
3
+ const defu = require('defu');
4
+ const imageMeta = require('image-meta');
5
+ const ufo = require('ufo');
6
+ const fs = require('fs');
7
+ const pathe = require('pathe');
8
+ const http = require('http');
9
+ const https = require('https');
10
+ const ohmyfetch = require('ohmyfetch');
11
+ const destr = require('destr');
12
+ const getEtag = require('etag');
13
+ const xss = require('xss');
14
+
15
+ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
16
+
17
+ const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
18
+ const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
19
+ const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
20
+ const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
21
+ const getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
22
+ const xss__default = /*#__PURE__*/_interopDefaultLegacy(xss);
23
+
24
+ const Handlers = {
25
+ __proto__: null,
26
+ get quality () { return quality; },
27
+ get fit () { return fit; },
28
+ get position () { return position; },
29
+ get background () { return background; },
30
+ get enlarge () { return enlarge; },
31
+ get width () { return width; },
32
+ get height () { return height; },
33
+ get resize () { return resize; },
34
+ get trim () { return trim; },
35
+ get extend () { return extend; },
36
+ get extract () { return extract; },
37
+ get rotate () { return rotate; },
38
+ get flip () { return flip; },
39
+ get flop () { return flop; },
40
+ get sharpen () { return sharpen; },
41
+ get median () { return median; },
42
+ get blur () { return blur; },
43
+ get flatten () { return flatten; },
44
+ get gamma () { return gamma; },
45
+ get negate () { return negate; },
46
+ get normalize () { return normalize; },
47
+ get threshold () { return threshold; },
48
+ get modulate () { return modulate; },
49
+ get tint () { return tint; },
50
+ get grayscale () { return grayscale; },
51
+ get crop () { return crop; },
52
+ get q () { return q; },
53
+ get b () { return b; },
54
+ get w () { return w; },
55
+ get h () { return h; },
56
+ get s () { return s; },
57
+ get pos () { return pos; }
58
+ };
59
+
60
+ function getEnv(name, defaultValue) {
61
+ return destr__default(process.env[name]) ?? defaultValue;
62
+ }
63
+ function cachedPromise(fn) {
64
+ let p;
65
+ return (...args) => {
66
+ if (p) {
67
+ return p;
68
+ }
69
+ p = Promise.resolve(fn(...args));
70
+ return p;
71
+ };
72
+ }
73
+ class IPXError extends Error {
74
+ }
75
+ function createError(statusMessage, statusCode, trace) {
76
+ const err = new IPXError(statusMessage + (trace ? ` (${trace})` : ""));
77
+ err.statusMessage = "IPX: " + statusMessage;
78
+ err.statusCode = statusCode;
79
+ return err;
80
+ }
81
+
82
+ const createFilesystemSource = (options) => {
83
+ const rootDir = pathe.resolve(options.dir);
84
+ return async (id) => {
85
+ const fsPath = pathe.resolve(pathe.join(rootDir, id));
86
+ if (!isValidPath(fsPath) || !fsPath.startsWith(rootDir)) {
87
+ throw createError("Forbidden path", 403, id);
88
+ }
89
+ let stats;
90
+ try {
91
+ stats = await fs.promises.stat(fsPath);
92
+ } catch (err) {
93
+ if (err.code === "ENOENT") {
94
+ throw createError("File not found", 404, fsPath);
95
+ } else {
96
+ throw createError("File access error " + err.code, 403, fsPath);
97
+ }
98
+ }
99
+ if (!stats.isFile()) {
100
+ throw createError("Path should be a file", 400, fsPath);
101
+ }
102
+ return {
103
+ mtime: stats.mtime,
104
+ maxAge: options.maxAge,
105
+ getData: cachedPromise(() => fs.promises.readFile(fsPath))
106
+ };
107
+ };
108
+ };
109
+ const isWindows = process.platform === "win32";
110
+ function isValidPath(fp) {
111
+ if (isWindows) {
112
+ fp = fp.slice(pathe.parse(fp).root.length);
113
+ }
114
+ if (/[<>:"|?*]/.test(fp)) {
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+
120
+ const createHTTPSource = (options) => {
121
+ const httpsAgent = new https__default.Agent({ keepAlive: true });
122
+ const httpAgent = new http__default.Agent({ keepAlive: true });
123
+ let domains = options.domains || [];
124
+ if (typeof domains === "string") {
125
+ domains = domains.split(",").map((s) => s.trim());
126
+ }
127
+ const hosts = domains.map((domain) => ufo.parseURL(domain, "https://").host);
128
+ return async (id, reqOptions) => {
129
+ const url = new URL(id);
130
+ if (!url.hostname) {
131
+ throw createError("Hostname is missing", 403, id);
132
+ }
133
+ if (!reqOptions?.bypassDomain && !hosts.find((host) => url.hostname === host)) {
134
+ throw createError("Forbidden host", 403, url.hostname);
135
+ }
136
+ const response = await ohmyfetch.fetch(id, {
137
+ agent: id.startsWith("https") ? httpsAgent : httpAgent,
138
+ ...options.fetchOptions
139
+ });
140
+ if (!response.ok) {
141
+ throw createError("Fetch error", response.status || 500, response.statusText);
142
+ }
143
+ let maxAge = options.maxAge;
144
+ const _cacheControl = response.headers.get("cache-control");
145
+ if (_cacheControl) {
146
+ const m = _cacheControl.match(/max-age=(\d+)/);
147
+ if (m && m[1]) {
148
+ maxAge = parseInt(m[1]);
149
+ }
150
+ }
151
+ let mtime;
152
+ const _lastModified = response.headers.get("last-modified");
153
+ if (_lastModified) {
154
+ mtime = new Date(_lastModified);
155
+ }
156
+ return {
157
+ mtime,
158
+ maxAge,
159
+ getData: cachedPromise(() => response.arrayBuffer().then((ab) => Buffer.from(ab)))
160
+ };
161
+ };
162
+ };
163
+
164
+ function VArg(arg) {
165
+ return destr__default(arg);
166
+ }
167
+ function parseArgs(args, mappers) {
168
+ const vargs = args.split("_");
169
+ return mappers.map((v, i) => v(vargs[i]));
170
+ }
171
+ function getHandler(key) {
172
+ return Handlers[key];
173
+ }
174
+ function applyHandler(ctx, pipe, handler, argsStr) {
175
+ const args = handler.args ? parseArgs(argsStr, handler.args) : [];
176
+ return handler.apply(ctx, pipe, ...args);
177
+ }
178
+ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
179
+ const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
180
+ let { width, height } = desiredDimensions;
181
+ if (width > sourceDimensions.width) {
182
+ width = sourceDimensions.width;
183
+ height = Math.round(sourceDimensions.width / desiredAspectRatio);
184
+ }
185
+ if (height > sourceDimensions.height) {
186
+ height = sourceDimensions.height;
187
+ width = Math.round(sourceDimensions.height * desiredAspectRatio);
188
+ }
189
+ return { width, height };
190
+ }
191
+
192
+ const quality = {
193
+ args: [VArg],
194
+ order: -1,
195
+ apply: (context, _pipe, quality2) => {
196
+ context.quality = quality2;
197
+ }
198
+ };
199
+ const fit = {
200
+ args: [VArg],
201
+ order: -1,
202
+ apply: (context, _pipe, fit2) => {
203
+ context.fit = fit2;
204
+ }
205
+ };
206
+ const position = {
207
+ args: [VArg],
208
+ order: -1,
209
+ apply: (context, _pipe, position2) => {
210
+ context.position = position2;
211
+ }
212
+ };
213
+ const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
214
+ const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
215
+ const background = {
216
+ args: [VArg],
217
+ order: -1,
218
+ apply: (context, _pipe, background2) => {
219
+ background2 = String(background2);
220
+ if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
221
+ background2 = "#" + background2;
222
+ }
223
+ context.background = background2;
224
+ }
225
+ };
226
+ const enlarge = {
227
+ args: [],
228
+ apply: (context) => {
229
+ context.enlarge = true;
230
+ }
231
+ };
232
+ const width = {
233
+ args: [VArg],
234
+ apply: (context, pipe, width2) => {
235
+ return pipe.resize(width2, null, { withoutEnlargement: !context.enlarge });
236
+ }
237
+ };
238
+ const height = {
239
+ args: [VArg],
240
+ apply: (context, pipe, height2) => {
241
+ return pipe.resize(null, height2, { withoutEnlargement: !context.enlarge });
242
+ }
243
+ };
244
+ const resize = {
245
+ args: [VArg, VArg, VArg],
246
+ apply: (context, pipe, size) => {
247
+ let [width2, height2] = String(size).split("x").map((v) => Number(v));
248
+ if (!width2) {
249
+ return;
250
+ }
251
+ if (!height2) {
252
+ height2 = width2;
253
+ }
254
+ if (!context.enlarge) {
255
+ const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
256
+ width2 = clamped.width;
257
+ height2 = clamped.height;
258
+ }
259
+ return pipe.resize(width2, height2, {
260
+ fit: context.fit,
261
+ position: context.position,
262
+ background: context.background
263
+ });
264
+ }
265
+ };
266
+ const trim = {
267
+ args: [VArg],
268
+ apply: (_context, pipe, threshold2) => {
269
+ return pipe.trim(threshold2);
270
+ }
271
+ };
272
+ const extend = {
273
+ args: [VArg, VArg, VArg, VArg],
274
+ apply: (context, pipe, top, right, bottom, left) => {
275
+ return pipe.extend({
276
+ top,
277
+ left,
278
+ bottom,
279
+ right,
280
+ background: context.background
281
+ });
282
+ }
283
+ };
284
+ const extract = {
285
+ args: [VArg, VArg, VArg, VArg],
286
+ apply: (context, pipe, top, right, bottom, left) => {
287
+ return pipe.extend({
288
+ top,
289
+ left,
290
+ bottom,
291
+ right,
292
+ background: context.background
293
+ });
294
+ }
295
+ };
296
+ const rotate = {
297
+ args: [VArg],
298
+ apply: (context, pipe, angel) => {
299
+ return pipe.rotate(angel, {
300
+ background: context.background
301
+ });
302
+ }
303
+ };
304
+ const flip = {
305
+ args: [],
306
+ apply: (_context, pipe) => {
307
+ return pipe.flip();
308
+ }
309
+ };
310
+ const flop = {
311
+ args: [],
312
+ apply: (_context, pipe) => {
313
+ return pipe.flop();
314
+ }
315
+ };
316
+ const sharpen = {
317
+ args: [VArg, VArg, VArg],
318
+ apply: (_context, pipe, sigma, flat, jagged) => {
319
+ return pipe.sharpen(sigma, flat, jagged);
320
+ }
321
+ };
322
+ const median = {
323
+ args: [VArg, VArg, VArg],
324
+ apply: (_context, pipe, size) => {
325
+ return pipe.median(size);
326
+ }
327
+ };
328
+ const blur = {
329
+ args: [VArg, VArg, VArg],
330
+ apply: (_context, pipe) => {
331
+ return pipe.blur();
332
+ }
333
+ };
334
+ const flatten = {
335
+ args: [VArg, VArg, VArg],
336
+ apply: (context, pipe) => {
337
+ return pipe.flatten({
338
+ background: context.background
339
+ });
340
+ }
341
+ };
342
+ const gamma = {
343
+ args: [VArg, VArg, VArg],
344
+ apply: (_context, pipe, gamma2, gammaOut) => {
345
+ return pipe.gamma(gamma2, gammaOut);
346
+ }
347
+ };
348
+ const negate = {
349
+ args: [VArg, VArg, VArg],
350
+ apply: (_context, pipe) => {
351
+ return pipe.negate();
352
+ }
353
+ };
354
+ const normalize = {
355
+ args: [VArg, VArg, VArg],
356
+ apply: (_context, pipe) => {
357
+ return pipe.normalize();
358
+ }
359
+ };
360
+ const threshold = {
361
+ args: [VArg],
362
+ apply: (_context, pipe, threshold2) => {
363
+ return pipe.threshold(threshold2);
364
+ }
365
+ };
366
+ const modulate = {
367
+ args: [VArg],
368
+ apply: (_context, pipe, brightness, saturation, hue) => {
369
+ return pipe.modulate({
370
+ brightness,
371
+ saturation,
372
+ hue
373
+ });
374
+ }
375
+ };
376
+ const tint = {
377
+ args: [VArg],
378
+ apply: (_context, pipe, rgb) => {
379
+ return pipe.tint(rgb);
380
+ }
381
+ };
382
+ const grayscale = {
383
+ args: [VArg],
384
+ apply: (_context, pipe) => {
385
+ return pipe.grayscale();
386
+ }
387
+ };
388
+ const crop = extract;
389
+ const q = quality;
390
+ const b = background;
391
+ const w = width;
392
+ const h = height;
393
+ const s = resize;
394
+ const pos = position;
395
+
396
+ const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff", "gif"];
397
+ function createIPX(userOptions) {
398
+ const defaults = {
399
+ dir: getEnv("IPX_DIR", "."),
400
+ domains: getEnv("IPX_DOMAINS", []),
401
+ alias: getEnv("IPX_ALIAS", {}),
402
+ fetchOptions: getEnv("IPX_FETCH_OPTIONS", {}),
403
+ maxAge: getEnv("IPX_MAX_AGE", 300),
404
+ sharp: {}
405
+ };
406
+ const options = defu__default(userOptions, defaults);
407
+ options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [ufo.withLeadingSlash(e[0]), e[1]]));
408
+ const ctx = {
409
+ sources: {}
410
+ };
411
+ if (options.dir) {
412
+ ctx.sources.filesystem = createFilesystemSource({
413
+ dir: options.dir,
414
+ maxAge: options.maxAge
415
+ });
416
+ }
417
+ if (options.domains) {
418
+ ctx.sources.http = createHTTPSource({
419
+ domains: options.domains,
420
+ fetchOptions: options.fetchOptions,
421
+ maxAge: options.maxAge
422
+ });
423
+ }
424
+ return function ipx(id, modifiers = {}, reqOptions = {}) {
425
+ if (!id) {
426
+ throw createError("resource id is missing", 400);
427
+ }
428
+ id = ufo.hasProtocol(id) ? id : ufo.withLeadingSlash(id);
429
+ for (const base in options.alias) {
430
+ if (id.startsWith(base)) {
431
+ id = ufo.joinURL(options.alias[base], id.substr(base.length));
432
+ }
433
+ }
434
+ const getSrc = cachedPromise(() => {
435
+ const source = ufo.hasProtocol(id) ? "http" : "filesystem";
436
+ if (!ctx.sources[source]) {
437
+ throw createError("Unknown source", 400, source);
438
+ }
439
+ return ctx.sources[source](id, reqOptions);
440
+ });
441
+ const getData = cachedPromise(async () => {
442
+ const src = await getSrc();
443
+ const data = await src.getData();
444
+ const meta = imageMeta.imageMeta(data);
445
+ const mFormat = modifiers.f || modifiers.format;
446
+ let format = mFormat || meta.type;
447
+ if (format === "jpg") {
448
+ format = "jpeg";
449
+ }
450
+ if (meta.type === "svg" && !mFormat) {
451
+ return {
452
+ data,
453
+ format: "svg+xml",
454
+ meta
455
+ };
456
+ }
457
+ const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
458
+ const Sharp = await import('sharp').then((r) => r.default || r);
459
+ let sharp = Sharp(data, { animated });
460
+ Object.assign(sharp.options, options.sharp);
461
+ const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
462
+ const aKey = (a.handler.order || a.name || "").toString();
463
+ const bKey = (b.handler.order || b.name || "").toString();
464
+ return aKey.localeCompare(bKey);
465
+ });
466
+ const handlerCtx = { meta };
467
+ for (const h of handlers) {
468
+ sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
469
+ }
470
+ if (SUPPORTED_FORMATS.includes(format)) {
471
+ sharp = sharp.toFormat(format, {
472
+ quality: handlerCtx.quality,
473
+ progressive: format === "jpeg"
474
+ });
475
+ }
476
+ const newData = await sharp.toBuffer();
477
+ return {
478
+ data: newData,
479
+ format,
480
+ meta
481
+ };
482
+ });
483
+ return {
484
+ src: getSrc,
485
+ data: getData
486
+ };
487
+ };
488
+ }
489
+
490
+ const MODIFIER_SEP = /[,&]/g;
491
+ const MODIFIER_VAL_SEP = /[_=:]/g;
492
+ async function _handleRequest(req, ipx) {
493
+ const res = {
494
+ statusCode: 200,
495
+ statusMessage: "",
496
+ headers: {},
497
+ body: ""
498
+ };
499
+ const [modifiersStr = "", ...idSegments] = req.url.substring(1).split("/");
500
+ const id = safeString(ufo.decode(idSegments.join("/")));
501
+ if (!modifiersStr) {
502
+ throw createError("Modifiers are missing", 400, req.url);
503
+ }
504
+ if (!id || id === "/") {
505
+ throw createError("Resource id is missing", 400, req.url);
506
+ }
507
+ const modifiers = /* @__PURE__ */ Object.create(null);
508
+ if (modifiersStr !== "_") {
509
+ for (const p of modifiersStr.split(MODIFIER_SEP)) {
510
+ const [key, value = ""] = p.split(MODIFIER_VAL_SEP);
511
+ modifiers[safeString(key)] = safeString(ufo.decode(value));
512
+ }
513
+ }
514
+ const img = ipx(id, modifiers, req.options);
515
+ const src = await img.src();
516
+ if (src.mtime) {
517
+ if (req.headers["if-modified-since"]) {
518
+ if (new Date(req.headers["if-modified-since"]) >= src.mtime) {
519
+ res.statusCode = 304;
520
+ return res;
521
+ }
522
+ }
523
+ res.headers["Last-Modified"] = +src.mtime + "";
524
+ }
525
+ if (typeof src.maxAge === "number") {
526
+ res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
527
+ }
528
+ const { data, format } = await img.data();
529
+ const etag = getEtag__default(data);
530
+ res.headers.ETag = etag;
531
+ if (etag && req.headers["if-none-match"] === etag) {
532
+ res.statusCode = 304;
533
+ return res;
534
+ }
535
+ if (format) {
536
+ res.headers["Content-Type"] = `image/${format}`;
537
+ }
538
+ res.body = data;
539
+ return sanetizeReponse(res);
540
+ }
541
+ function handleRequest(req, ipx) {
542
+ return _handleRequest(req, ipx).catch((err) => {
543
+ const statusCode = parseInt(err.statusCode) || 500;
544
+ const statusMessage = err.statusMessage ? err.statusMessage : `IPX Error (${statusCode})`;
545
+ if (process.env.NODE_ENV !== "production" && statusCode === 500) {
546
+ console.error(err);
547
+ }
548
+ return sanetizeReponse({
549
+ statusCode,
550
+ statusMessage,
551
+ body: "IPX Error: " + err,
552
+ headers: {}
553
+ });
554
+ });
555
+ }
556
+ function createIPXMiddleware(ipx) {
557
+ return function IPXMiddleware(req, res) {
558
+ handleRequest({ url: req.url, headers: req.headers }, ipx).then((_res) => {
559
+ res.statusCode = _res.statusCode;
560
+ res.statusMessage = _res.statusMessage;
561
+ for (const name in _res.headers) {
562
+ res.setHeader(name, _res.headers[name]);
563
+ }
564
+ res.end(_res.body);
565
+ });
566
+ };
567
+ }
568
+ function sanetizeReponse(res) {
569
+ return {
570
+ statusCode: res.statusCode || 200,
571
+ statusMessage: res.statusMessage ? safeString(res.statusMessage) : "OK",
572
+ headers: safeStringObject(res.headers || {}),
573
+ body: typeof res.body === "string" ? xss__default(safeString(res.body)) : res.body || ""
574
+ };
575
+ }
576
+ function safeString(input) {
577
+ return JSON.stringify(input).replace(/^"|"$/g, "");
578
+ }
579
+ function safeStringObject(input) {
580
+ const dst = {};
581
+ for (const key in input) {
582
+ dst[key] = safeString(input[key]);
583
+ }
584
+ return dst;
585
+ }
586
+
587
+ exports.createIPX = createIPX;
588
+ exports.createIPXMiddleware = createIPXMiddleware;
589
+ exports.handleRequest = handleRequest;
@@ -2,8 +2,7 @@ import defu from 'defu';
2
2
  import { imageMeta } from 'image-meta';
3
3
  import { parseURL, withLeadingSlash, hasProtocol, joinURL, decode } from 'ufo';
4
4
  import { promises } from 'fs';
5
- import { resolve, join } from 'pathe';
6
- import isValidPath from 'is-valid-path';
5
+ import { resolve, join, parse } from 'pathe';
7
6
  import http from 'http';
8
7
  import https from 'https';
9
8
  import { fetch } from 'ohmyfetch';
@@ -62,9 +61,9 @@ function cachedPromise(fn) {
62
61
  }
63
62
  class IPXError extends Error {
64
63
  }
65
- function createError(message, statusCode) {
66
- const err = new IPXError(message);
67
- err.statusMessage = "IPX: " + message;
64
+ function createError(statusMessage, statusCode, trace) {
65
+ const err = new IPXError(statusMessage + (trace ? ` (${trace})` : ""));
66
+ err.statusMessage = "IPX: " + statusMessage;
68
67
  err.statusCode = statusCode;
69
68
  return err;
70
69
  }
@@ -73,29 +72,39 @@ const createFilesystemSource = (options) => {
73
72
  const rootDir = resolve(options.dir);
74
73
  return async (id) => {
75
74
  const fsPath = resolve(join(rootDir, id));
76
- if (!isValidPath(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
77
- throw createError("Forbidden path:" + id, 403);
75
+ if (!isValidPath(fsPath) || !fsPath.startsWith(rootDir)) {
76
+ throw createError("Forbidden path", 403, id);
78
77
  }
79
78
  let stats;
80
79
  try {
81
80
  stats = await promises.stat(fsPath);
82
81
  } catch (err) {
83
82
  if (err.code === "ENOENT") {
84
- throw createError("File not found: " + fsPath, 404);
83
+ throw createError("File not found", 404, fsPath);
85
84
  } else {
86
- throw createError("File access error for " + fsPath + ":" + err.code, 403);
85
+ throw createError("File access error " + err.code, 403, fsPath);
87
86
  }
88
87
  }
89
88
  if (!stats.isFile()) {
90
- throw createError("Path should be a file: " + fsPath, 400);
89
+ throw createError("Path should be a file", 400, fsPath);
91
90
  }
92
91
  return {
93
92
  mtime: stats.mtime,
94
- maxAge: options.maxAge || 300,
93
+ maxAge: options.maxAge,
95
94
  getData: cachedPromise(() => promises.readFile(fsPath))
96
95
  };
97
96
  };
98
97
  };
98
+ const isWindows = process.platform === "win32";
99
+ function isValidPath(fp) {
100
+ if (isWindows) {
101
+ fp = fp.slice(parse(fp).root.length);
102
+ }
103
+ if (/[<>:"|?*]/.test(fp)) {
104
+ return false;
105
+ }
106
+ return true;
107
+ }
99
108
 
100
109
  const createHTTPSource = (options) => {
101
110
  const httpsAgent = new https.Agent({ keepAlive: true });
@@ -108,18 +117,19 @@ const createHTTPSource = (options) => {
108
117
  return async (id, reqOptions) => {
109
118
  const url = new URL(id);
110
119
  if (!url.hostname) {
111
- throw createError("Hostname is missing: " + id, 403);
120
+ throw createError("Hostname is missing", 403, id);
112
121
  }
113
122
  if (!reqOptions?.bypassDomain && !hosts.find((host) => url.hostname === host)) {
114
- throw createError("Forbidden host: " + url.hostname, 403);
123
+ throw createError("Forbidden host", 403, url.hostname);
115
124
  }
116
125
  const response = await fetch(id, {
117
- agent: id.startsWith("https") ? httpsAgent : httpAgent
126
+ agent: id.startsWith("https") ? httpsAgent : httpAgent,
127
+ ...options.fetchOptions
118
128
  });
119
129
  if (!response.ok) {
120
- throw createError(response.statusText || "fetch error", response.status || 500);
130
+ throw createError("Fetch error", response.status || 500, response.statusText);
121
131
  }
122
- let maxAge = options.maxAge || 300;
132
+ let maxAge = options.maxAge;
123
133
  const _cacheControl = response.headers.get("cache-control");
124
134
  if (_cacheControl) {
125
135
  const m = _cacheControl.match(/max-age=(\d+)/);
@@ -135,7 +145,7 @@ const createHTTPSource = (options) => {
135
145
  return {
136
146
  mtime,
137
147
  maxAge,
138
- getData: cachedPromise(() => response.buffer())
148
+ getData: cachedPromise(() => response.arrayBuffer().then((ab) => Buffer.from(ab)))
139
149
  };
140
150
  };
141
151
  };
@@ -372,12 +382,14 @@ const h = height;
372
382
  const s = resize;
373
383
  const pos = position;
374
384
 
375
- const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
385
+ const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff", "gif"];
376
386
  function createIPX(userOptions) {
377
387
  const defaults = {
378
388
  dir: getEnv("IPX_DIR", "."),
379
389
  domains: getEnv("IPX_DOMAINS", []),
380
390
  alias: getEnv("IPX_ALIAS", {}),
391
+ fetchOptions: getEnv("IPX_FETCH_OPTIONS", {}),
392
+ maxAge: getEnv("IPX_MAX_AGE", 300),
381
393
  sharp: {}
382
394
  };
383
395
  const options = defu(userOptions, defaults);
@@ -387,12 +399,15 @@ function createIPX(userOptions) {
387
399
  };
388
400
  if (options.dir) {
389
401
  ctx.sources.filesystem = createFilesystemSource({
390
- dir: options.dir
402
+ dir: options.dir,
403
+ maxAge: options.maxAge
391
404
  });
392
405
  }
393
406
  if (options.domains) {
394
407
  ctx.sources.http = createHTTPSource({
395
- domains: options.domains
408
+ domains: options.domains,
409
+ fetchOptions: options.fetchOptions,
410
+ maxAge: options.maxAge
396
411
  });
397
412
  }
398
413
  return function ipx(id, modifiers = {}, reqOptions = {}) {
@@ -408,7 +423,7 @@ function createIPX(userOptions) {
408
423
  const getSrc = cachedPromise(() => {
409
424
  const source = hasProtocol(id) ? "http" : "filesystem";
410
425
  if (!ctx.sources[source]) {
411
- throw createError("Unknown source: " + source, 400);
426
+ throw createError("Unknown source", 400, source);
412
427
  }
413
428
  return ctx.sources[source](id, reqOptions);
414
429
  });
@@ -428,10 +443,7 @@ function createIPX(userOptions) {
428
443
  meta
429
444
  };
430
445
  }
431
- const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
432
- if (animated) {
433
- format = "webp";
434
- }
446
+ const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
435
447
  const Sharp = await import('sharp').then((r) => r.default || r);
436
448
  let sharp = Sharp(data, { animated });
437
449
  Object.assign(sharp.options, options.sharp);
@@ -464,6 +476,8 @@ function createIPX(userOptions) {
464
476
  };
465
477
  }
466
478
 
479
+ const MODIFIER_SEP = /[,&]/g;
480
+ const MODIFIER_VAL_SEP = /[_=:]/g;
467
481
  async function _handleRequest(req, ipx) {
468
482
  const res = {
469
483
  statusCode: 200,
@@ -471,19 +485,19 @@ async function _handleRequest(req, ipx) {
471
485
  headers: {},
472
486
  body: ""
473
487
  };
474
- const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
475
- const id = decode(idSegments.join("/"));
488
+ const [modifiersStr = "", ...idSegments] = req.url.substring(1).split("/");
489
+ const id = safeString(decode(idSegments.join("/")));
476
490
  if (!modifiersStr) {
477
- throw createError("Modifiers is missing in path: " + req.url, 400);
491
+ throw createError("Modifiers are missing", 400, req.url);
478
492
  }
479
493
  if (!id || id === "/") {
480
- throw createError("Resource id is missing: " + req.url, 400);
494
+ throw createError("Resource id is missing", 400, req.url);
481
495
  }
482
496
  const modifiers = /* @__PURE__ */ Object.create(null);
483
497
  if (modifiersStr !== "_") {
484
- for (const p of modifiersStr.split(",")) {
485
- const [key, value = ""] = p.split("_");
486
- modifiers[key] = decode(value);
498
+ for (const p of modifiersStr.split(MODIFIER_SEP)) {
499
+ const [key, value = ""] = p.split(MODIFIER_VAL_SEP);
500
+ modifiers[safeString(key)] = safeString(decode(value));
487
501
  }
488
502
  }
489
503
  const img = ipx(id, modifiers, req.options);
@@ -497,7 +511,7 @@ async function _handleRequest(req, ipx) {
497
511
  }
498
512
  res.headers["Last-Modified"] = +src.mtime + "";
499
513
  }
500
- if (src.maxAge !== void 0) {
514
+ if (typeof src.maxAge === "number") {
501
515
  res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
502
516
  }
503
517
  const { data, format } = await img.data();
@@ -511,21 +525,21 @@ async function _handleRequest(req, ipx) {
511
525
  res.headers["Content-Type"] = `image/${format}`;
512
526
  }
513
527
  res.body = data;
514
- return res;
528
+ return sanetizeReponse(res);
515
529
  }
516
530
  function handleRequest(req, ipx) {
517
531
  return _handleRequest(req, ipx).catch((err) => {
518
532
  const statusCode = parseInt(err.statusCode) || 500;
519
- const statusMessage = err.statusMessage ? xss(err.statusMessage) : `IPX Error (${statusCode})`;
533
+ const statusMessage = err.statusMessage ? err.statusMessage : `IPX Error (${statusCode})`;
520
534
  if (process.env.NODE_ENV !== "production" && statusCode === 500) {
521
535
  console.error(err);
522
536
  }
523
- return {
537
+ return sanetizeReponse({
524
538
  statusCode,
525
539
  statusMessage,
526
- body: statusMessage,
540
+ body: "IPX Error: " + err,
527
541
  headers: {}
528
- };
542
+ });
529
543
  });
530
544
  }
531
545
  function createIPXMiddleware(ipx) {
@@ -540,5 +554,23 @@ function createIPXMiddleware(ipx) {
540
554
  });
541
555
  };
542
556
  }
557
+ function sanetizeReponse(res) {
558
+ return {
559
+ statusCode: res.statusCode || 200,
560
+ statusMessage: res.statusMessage ? safeString(res.statusMessage) : "OK",
561
+ headers: safeStringObject(res.headers || {}),
562
+ body: typeof res.body === "string" ? xss(safeString(res.body)) : res.body || ""
563
+ };
564
+ }
565
+ function safeString(input) {
566
+ return JSON.stringify(input).replace(/^"|"$/g, "");
567
+ }
568
+ function safeStringObject(input) {
569
+ const dst = {};
570
+ for (const key in input) {
571
+ dst[key] = safeString(input[key]);
572
+ }
573
+ return dst;
574
+ }
543
575
 
544
576
  export { createIPXMiddleware as a, createIPX as c, handleRequest as h };
package/dist/cli.cjs ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const consola = require('consola');
4
+ const listhen = require('listhen');
5
+ const middleware = require('./chunks/middleware.cjs');
6
+ require('defu');
7
+ require('image-meta');
8
+ require('ufo');
9
+ require('fs');
10
+ require('pathe');
11
+ require('http');
12
+ require('https');
13
+ require('ohmyfetch');
14
+ require('destr');
15
+ require('etag');
16
+ require('xss');
17
+
18
+ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
19
+
20
+ const consola__default = /*#__PURE__*/_interopDefaultLegacy(consola);
21
+
22
+ async function main() {
23
+ const ipx = middleware.createIPX({});
24
+ const middleware$1 = middleware.createIPXMiddleware(ipx);
25
+ await listhen.listen(middleware$1, {
26
+ clipboard: false
27
+ });
28
+ }
29
+ main().catch((err) => {
30
+ consola__default.error(err);
31
+ process.exit(1);
32
+ });
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 ADDED
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ const middleware = require('./chunks/middleware.cjs');
6
+ require('defu');
7
+ require('image-meta');
8
+ require('ufo');
9
+ require('fs');
10
+ require('pathe');
11
+ require('http');
12
+ require('https');
13
+ require('ohmyfetch');
14
+ require('destr');
15
+ require('etag');
16
+ require('xss');
17
+
18
+
19
+
20
+ exports.createIPX = middleware.createIPX;
21
+ exports.createIPXMiddleware = middleware.createIPXMiddleware;
22
+ exports.handleRequest = middleware.handleRequest;
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.3",
3
+ "version": "0.9.7",
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.1",
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.6",
37
- "ohmyfetch": "^0.4.15",
38
- "pathe": "^0.2.0",
39
- "sharp": "^0.30.1",
40
- "ufo": "^0.7.10",
41
- "xss": "^1.0.10"
26
+ "listhen": "^0.2.13",
27
+ "ohmyfetch": "^0.4.18",
28
+ "pathe": "^0.3.0",
29
+ "sharp": "^0.30.7",
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
+ }