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 +0 -0
- package/README.md +23 -21
- package/dist/chunks/middleware.cjs +84 -40
- package/dist/chunks/middleware.mjs +84 -39
- package/dist/cli.cjs +0 -1
- package/dist/cli.mjs +0 -1
- package/dist/index.cjs +0 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.mjs +0 -1
- package/package.json +25 -26
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
|
|
56
|
-
|
|
|
57
|
-
| width / w
|
|
58
|
-
| height / h
|
|
59
|
-
| resize / s
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
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(
|
|
76
|
-
const err = new IPXError(
|
|
77
|
-
err.statusMessage = "IPX: " +
|
|
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 (!
|
|
87
|
-
throw createError("Forbidden path
|
|
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
|
|
94
|
+
throw createError("File not found", 404, fsPath);
|
|
95
95
|
} else {
|
|
96
|
-
throw createError("File access error
|
|
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
|
|
100
|
+
throw createError("Path should be a file", 400, fsPath);
|
|
101
101
|
}
|
|
102
102
|
return {
|
|
103
103
|
mtime: stats.mtime,
|
|
104
|
-
maxAge: options.maxAge
|
|
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
|
|
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
|
|
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(
|
|
137
|
+
throw createError("Fetch error", response.status || 500, response.statusText);
|
|
131
138
|
}
|
|
132
|
-
let maxAge = options.maxAge
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
498
|
+
throw createError("Modifiers are missing", 400, req.url);
|
|
473
499
|
}
|
|
474
500
|
if (!id || id === "/") {
|
|
475
|
-
throw createError("Resource id is missing
|
|
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
|
|
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 ?
|
|
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:
|
|
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(
|
|
64
|
-
const err = new IPXError(
|
|
65
|
-
err.statusMessage = "IPX: " +
|
|
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(
|
|
75
|
-
throw createError("Forbidden path
|
|
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
|
|
83
|
+
throw createError("File not found", 404, fsPath);
|
|
83
84
|
} else {
|
|
84
|
-
throw createError("File access error
|
|
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
|
|
89
|
+
throw createError("Path should be a file", 400, fsPath);
|
|
89
90
|
}
|
|
90
91
|
return {
|
|
91
92
|
mtime: stats.mtime,
|
|
92
|
-
maxAge: options.maxAge
|
|
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
|
|
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
|
|
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(
|
|
126
|
+
throw createError("Fetch error", response.status || 500, response.statusText);
|
|
119
127
|
}
|
|
120
|
-
let maxAge = options.maxAge
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
487
|
+
throw createError("Modifiers are missing", 400, req.url);
|
|
461
488
|
}
|
|
462
489
|
if (!id || id === "/") {
|
|
463
|
-
throw createError("Resource id is missing
|
|
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
|
|
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 ?
|
|
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:
|
|
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
package/dist/cli.mjs
CHANGED
package/dist/index.cjs
CHANGED
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ipx",
|
|
3
|
-
"version": "0.9.
|
|
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": "^
|
|
32
|
-
"destr": "^1.1.
|
|
22
|
+
"defu": "^6.0.0",
|
|
23
|
+
"destr": "^1.1.1",
|
|
33
24
|
"etag": "^1.8.1",
|
|
34
25
|
"image-meta": "^0.1.1",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
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
|
+
}
|