ipx 0.8.1 → 0.9.3
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/README.md +25 -23
- package/bin/ipx.mjs +2 -0
- package/dist/{index.cjs → chunks/middleware.mjs} +58 -61
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +27 -0
- package/dist/index.mjs +13 -528
- package/package.json +16 -16
- package/dist/cli.js +0 -558
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
High performance, secure and easy to use image proxy based on [sharp](https://github.com/lovell/sharp) and [libvips](https://github.com/libvips/libvips).
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Usage
|
|
10
10
|
|
|
11
11
|
### Quick Start
|
|
12
12
|
|
|
@@ -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
|
|
|
@@ -85,6 +87,6 @@ Config can be customized using `IPX_*` environment variables.
|
|
|
85
87
|
- `IPX_DOMAINS`
|
|
86
88
|
- Default: `[]`
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
## License
|
|
89
91
|
|
|
90
92
|
MIT
|
package/bin/ipx.mjs
ADDED
|
@@ -1,38 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
import defu from 'defu';
|
|
2
|
+
import { imageMeta } from 'image-meta';
|
|
3
|
+
import { parseURL, withLeadingSlash, hasProtocol, joinURL, decode } from 'ufo';
|
|
4
|
+
import { promises } from 'fs';
|
|
5
|
+
import { resolve, join } from 'pathe';
|
|
6
|
+
import isValidPath from 'is-valid-path';
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import https from 'https';
|
|
9
|
+
import { fetch } from 'ohmyfetch';
|
|
10
|
+
import destr from 'destr';
|
|
11
|
+
import getEtag from 'etag';
|
|
12
|
+
import xss from 'xss';
|
|
2
13
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const Sharp = require('sharp');
|
|
6
|
-
const defu = require('defu');
|
|
7
|
-
const imageMeta = require('image-meta');
|
|
8
|
-
const ufo = require('ufo');
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const isValidPath = require('is-valid-path');
|
|
11
|
-
const fsExtra = require('fs-extra');
|
|
12
|
-
const destr = require('destr');
|
|
13
|
-
const http = require('http');
|
|
14
|
-
const https = require('https');
|
|
15
|
-
const fetch = require('node-fetch');
|
|
16
|
-
const getEtag = require('etag');
|
|
17
|
-
const xss = require('xss');
|
|
18
|
-
|
|
19
|
-
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
20
|
-
|
|
21
|
-
const Sharp__default = /*#__PURE__*/_interopDefaultLegacy(Sharp);
|
|
22
|
-
const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
|
|
23
|
-
const imageMeta__default = /*#__PURE__*/_interopDefaultLegacy(imageMeta);
|
|
24
|
-
const isValidPath__default = /*#__PURE__*/_interopDefaultLegacy(isValidPath);
|
|
25
|
-
const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
|
|
26
|
-
const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
|
|
27
|
-
const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
|
|
28
|
-
const fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
|
|
29
|
-
const getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
|
|
30
|
-
const xss__default = /*#__PURE__*/_interopDefaultLegacy(xss);
|
|
31
|
-
|
|
32
|
-
const Handlers = /*#__PURE__*/Object.freeze({
|
|
14
|
+
const Handlers = {
|
|
33
15
|
__proto__: null,
|
|
34
16
|
get quality () { return quality; },
|
|
35
17
|
get fit () { return fit; },
|
|
18
|
+
get position () { return position; },
|
|
36
19
|
get background () { return background; },
|
|
37
20
|
get enlarge () { return enlarge; },
|
|
38
21
|
get width () { return width; },
|
|
@@ -60,12 +43,12 @@ const Handlers = /*#__PURE__*/Object.freeze({
|
|
|
60
43
|
get b () { return b; },
|
|
61
44
|
get w () { return w; },
|
|
62
45
|
get h () { return h; },
|
|
63
|
-
get s () { return s; }
|
|
64
|
-
|
|
46
|
+
get s () { return s; },
|
|
47
|
+
get pos () { return pos; }
|
|
48
|
+
};
|
|
65
49
|
|
|
66
50
|
function getEnv(name, defaultValue) {
|
|
67
|
-
|
|
68
|
-
return (_a = destr__default['default'](process.env[name])) != null ? _a : defaultValue;
|
|
51
|
+
return destr(process.env[name]) ?? defaultValue;
|
|
69
52
|
}
|
|
70
53
|
function cachedPromise(fn) {
|
|
71
54
|
let p;
|
|
@@ -87,15 +70,15 @@ function createError(message, statusCode) {
|
|
|
87
70
|
}
|
|
88
71
|
|
|
89
72
|
const createFilesystemSource = (options) => {
|
|
90
|
-
const rootDir =
|
|
73
|
+
const rootDir = resolve(options.dir);
|
|
91
74
|
return async (id) => {
|
|
92
|
-
const fsPath =
|
|
93
|
-
if (!
|
|
75
|
+
const fsPath = resolve(join(rootDir, id));
|
|
76
|
+
if (!isValidPath(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
|
|
94
77
|
throw createError("Forbidden path:" + id, 403);
|
|
95
78
|
}
|
|
96
79
|
let stats;
|
|
97
80
|
try {
|
|
98
|
-
stats = await
|
|
81
|
+
stats = await promises.stat(fsPath);
|
|
99
82
|
} catch (err) {
|
|
100
83
|
if (err.code === "ENOENT") {
|
|
101
84
|
throw createError("File not found: " + fsPath, 404);
|
|
@@ -109,28 +92,28 @@ const createFilesystemSource = (options) => {
|
|
|
109
92
|
return {
|
|
110
93
|
mtime: stats.mtime,
|
|
111
94
|
maxAge: options.maxAge || 300,
|
|
112
|
-
getData: cachedPromise(() =>
|
|
95
|
+
getData: cachedPromise(() => promises.readFile(fsPath))
|
|
113
96
|
};
|
|
114
97
|
};
|
|
115
98
|
};
|
|
116
99
|
|
|
117
100
|
const createHTTPSource = (options) => {
|
|
118
|
-
const httpsAgent = new
|
|
119
|
-
const httpAgent = new
|
|
101
|
+
const httpsAgent = new https.Agent({ keepAlive: true });
|
|
102
|
+
const httpAgent = new http.Agent({ keepAlive: true });
|
|
120
103
|
let domains = options.domains || [];
|
|
121
104
|
if (typeof domains === "string") {
|
|
122
105
|
domains = domains.split(",").map((s) => s.trim());
|
|
123
106
|
}
|
|
124
|
-
const hosts = domains.map((domain) =>
|
|
107
|
+
const hosts = domains.map((domain) => parseURL(domain, "https://").host);
|
|
125
108
|
return async (id, reqOptions) => {
|
|
126
109
|
const url = new URL(id);
|
|
127
110
|
if (!url.hostname) {
|
|
128
111
|
throw createError("Hostname is missing: " + id, 403);
|
|
129
112
|
}
|
|
130
|
-
if (!
|
|
113
|
+
if (!reqOptions?.bypassDomain && !hosts.find((host) => url.hostname === host)) {
|
|
131
114
|
throw createError("Forbidden host: " + url.hostname, 403);
|
|
132
115
|
}
|
|
133
|
-
const response = await
|
|
116
|
+
const response = await fetch(id, {
|
|
134
117
|
agent: id.startsWith("https") ? httpsAgent : httpAgent
|
|
135
118
|
});
|
|
136
119
|
if (!response.ok) {
|
|
@@ -158,7 +141,7 @@ const createHTTPSource = (options) => {
|
|
|
158
141
|
};
|
|
159
142
|
|
|
160
143
|
function VArg(arg) {
|
|
161
|
-
return
|
|
144
|
+
return destr(arg);
|
|
162
145
|
}
|
|
163
146
|
function parseArgs(args, mappers) {
|
|
164
147
|
const vargs = args.split("_");
|
|
@@ -199,6 +182,13 @@ const fit = {
|
|
|
199
182
|
context.fit = fit2;
|
|
200
183
|
}
|
|
201
184
|
};
|
|
185
|
+
const position = {
|
|
186
|
+
args: [VArg],
|
|
187
|
+
order: -1,
|
|
188
|
+
apply: (context, _pipe, position2) => {
|
|
189
|
+
context.position = position2;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
202
192
|
const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
|
|
203
193
|
const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
|
|
204
194
|
const background = {
|
|
@@ -234,6 +224,12 @@ const resize = {
|
|
|
234
224
|
args: [VArg, VArg, VArg],
|
|
235
225
|
apply: (context, pipe, size) => {
|
|
236
226
|
let [width2, height2] = String(size).split("x").map((v) => Number(v));
|
|
227
|
+
if (!width2) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!height2) {
|
|
231
|
+
height2 = width2;
|
|
232
|
+
}
|
|
237
233
|
if (!context.enlarge) {
|
|
238
234
|
const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
|
|
239
235
|
width2 = clamped.width;
|
|
@@ -241,6 +237,7 @@ const resize = {
|
|
|
241
237
|
}
|
|
242
238
|
return pipe.resize(width2, height2, {
|
|
243
239
|
fit: context.fit,
|
|
240
|
+
position: context.position,
|
|
244
241
|
background: context.background
|
|
245
242
|
});
|
|
246
243
|
}
|
|
@@ -373,6 +370,7 @@ const b = background;
|
|
|
373
370
|
const w = width;
|
|
374
371
|
const h = height;
|
|
375
372
|
const s = resize;
|
|
373
|
+
const pos = position;
|
|
376
374
|
|
|
377
375
|
const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
|
|
378
376
|
function createIPX(userOptions) {
|
|
@@ -382,8 +380,8 @@ function createIPX(userOptions) {
|
|
|
382
380
|
alias: getEnv("IPX_ALIAS", {}),
|
|
383
381
|
sharp: {}
|
|
384
382
|
};
|
|
385
|
-
const options =
|
|
386
|
-
options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [
|
|
383
|
+
const options = defu(userOptions, defaults);
|
|
384
|
+
options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [withLeadingSlash(e[0]), e[1]]));
|
|
387
385
|
const ctx = {
|
|
388
386
|
sources: {}
|
|
389
387
|
};
|
|
@@ -401,14 +399,14 @@ function createIPX(userOptions) {
|
|
|
401
399
|
if (!id) {
|
|
402
400
|
throw createError("resource id is missing", 400);
|
|
403
401
|
}
|
|
404
|
-
id =
|
|
402
|
+
id = hasProtocol(id) ? id : withLeadingSlash(id);
|
|
405
403
|
for (const base in options.alias) {
|
|
406
404
|
if (id.startsWith(base)) {
|
|
407
|
-
id =
|
|
405
|
+
id = joinURL(options.alias[base], id.substr(base.length));
|
|
408
406
|
}
|
|
409
407
|
}
|
|
410
408
|
const getSrc = cachedPromise(() => {
|
|
411
|
-
const source =
|
|
409
|
+
const source = hasProtocol(id) ? "http" : "filesystem";
|
|
412
410
|
if (!ctx.sources[source]) {
|
|
413
411
|
throw createError("Unknown source: " + source, 400);
|
|
414
412
|
}
|
|
@@ -417,7 +415,7 @@ function createIPX(userOptions) {
|
|
|
417
415
|
const getData = cachedPromise(async () => {
|
|
418
416
|
const src = await getSrc();
|
|
419
417
|
const data = await src.getData();
|
|
420
|
-
const meta =
|
|
418
|
+
const meta = imageMeta(data);
|
|
421
419
|
const mFormat = modifiers.f || modifiers.format;
|
|
422
420
|
let format = mFormat || meta.type;
|
|
423
421
|
if (format === "jpg") {
|
|
@@ -434,7 +432,8 @@ function createIPX(userOptions) {
|
|
|
434
432
|
if (animated) {
|
|
435
433
|
format = "webp";
|
|
436
434
|
}
|
|
437
|
-
|
|
435
|
+
const Sharp = await import('sharp').then((r) => r.default || r);
|
|
436
|
+
let sharp = Sharp(data, { animated });
|
|
438
437
|
Object.assign(sharp.options, options.sharp);
|
|
439
438
|
const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
|
|
440
439
|
const aKey = (a.handler.order || a.name || "").toString();
|
|
@@ -473,18 +472,18 @@ async function _handleRequest(req, ipx) {
|
|
|
473
472
|
body: ""
|
|
474
473
|
};
|
|
475
474
|
const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
|
|
476
|
-
const id =
|
|
475
|
+
const id = decode(idSegments.join("/"));
|
|
477
476
|
if (!modifiersStr) {
|
|
478
477
|
throw createError("Modifiers is missing in path: " + req.url, 400);
|
|
479
478
|
}
|
|
480
479
|
if (!id || id === "/") {
|
|
481
480
|
throw createError("Resource id is missing: " + req.url, 400);
|
|
482
481
|
}
|
|
483
|
-
const modifiers = Object.create(null);
|
|
482
|
+
const modifiers = /* @__PURE__ */ Object.create(null);
|
|
484
483
|
if (modifiersStr !== "_") {
|
|
485
484
|
for (const p of modifiersStr.split(",")) {
|
|
486
485
|
const [key, value = ""] = p.split("_");
|
|
487
|
-
modifiers[key] =
|
|
486
|
+
modifiers[key] = decode(value);
|
|
488
487
|
}
|
|
489
488
|
}
|
|
490
489
|
const img = ipx(id, modifiers, req.options);
|
|
@@ -502,7 +501,7 @@ async function _handleRequest(req, ipx) {
|
|
|
502
501
|
res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
|
|
503
502
|
}
|
|
504
503
|
const { data, format } = await img.data();
|
|
505
|
-
const etag =
|
|
504
|
+
const etag = getEtag(data);
|
|
506
505
|
res.headers.ETag = etag;
|
|
507
506
|
if (etag && req.headers["if-none-match"] === etag) {
|
|
508
507
|
res.statusCode = 304;
|
|
@@ -517,7 +516,7 @@ async function _handleRequest(req, ipx) {
|
|
|
517
516
|
function handleRequest(req, ipx) {
|
|
518
517
|
return _handleRequest(req, ipx).catch((err) => {
|
|
519
518
|
const statusCode = parseInt(err.statusCode) || 500;
|
|
520
|
-
const statusMessage = err.statusMessage ?
|
|
519
|
+
const statusMessage = err.statusMessage ? xss(err.statusMessage) : `IPX Error (${statusCode})`;
|
|
521
520
|
if (process.env.NODE_ENV !== "production" && statusCode === 500) {
|
|
522
521
|
console.error(err);
|
|
523
522
|
}
|
|
@@ -542,6 +541,4 @@ function createIPXMiddleware(ipx) {
|
|
|
542
541
|
};
|
|
543
542
|
}
|
|
544
543
|
|
|
545
|
-
|
|
546
|
-
exports.createIPXMiddleware = createIPXMiddleware;
|
|
547
|
-
exports.handleRequest = handleRequest;
|
|
544
|
+
export { createIPXMiddleware as a, createIPX as c, handleRequest as h };
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import consola from 'consola';
|
|
2
|
+
import { listen } from 'listhen';
|
|
3
|
+
import { c as createIPX, a as createIPXMiddleware } from './chunks/middleware.mjs';
|
|
4
|
+
import 'defu';
|
|
5
|
+
import 'image-meta';
|
|
6
|
+
import 'ufo';
|
|
7
|
+
import 'fs';
|
|
8
|
+
import 'pathe';
|
|
9
|
+
import 'is-valid-path';
|
|
10
|
+
import 'http';
|
|
11
|
+
import 'https';
|
|
12
|
+
import 'ohmyfetch';
|
|
13
|
+
import 'destr';
|
|
14
|
+
import 'etag';
|
|
15
|
+
import 'xss';
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const ipx = createIPX({});
|
|
19
|
+
const middleware = createIPXMiddleware(ipx);
|
|
20
|
+
await listen(middleware, {
|
|
21
|
+
clipboard: false
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
main().catch((err) => {
|
|
25
|
+
consola.error(err);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|