1 | import Sharp from 'sharp';
|
2 | import defu from 'defu';
|
3 | import imageMeta from 'image-meta';
|
4 | import { parseURL, withLeadingSlash, hasProtocol, joinURL, normalizeURL, parseQuery, withoutLeadingSlash, decode } from 'ufo';
|
5 | import { resolve, join } from 'path';
|
6 | import isValidPath from 'is-valid-path';
|
7 | import { stat, readFile } from 'fs-extra';
|
8 | import destr from 'destr';
|
9 | import http from 'http';
|
10 | import https from 'https';
|
11 | import fetch from 'node-fetch';
|
12 | import getEtag from 'etag';
|
13 | import xss from 'xss';
|
14 |
|
15 | var Handlers = Object.freeze({
|
16 | __proto__: null,
|
17 | get quality () { return quality; },
|
18 | get fit () { return fit; },
|
19 | get background () { return background; },
|
20 | get width () { return width; },
|
21 | get height () { return height; },
|
22 | get resize () { return resize; },
|
23 | get trim () { return trim; },
|
24 | get extend () { return extend; },
|
25 | get extract () { return extract; },
|
26 | get rotate () { return rotate; },
|
27 | get flip () { return flip; },
|
28 | get flop () { return flop; },
|
29 | get sharpen () { return sharpen; },
|
30 | get median () { return median; },
|
31 | get blur () { return blur; },
|
32 | get flatten () { return flatten; },
|
33 | get gamma () { return gamma; },
|
34 | get negate () { return negate; },
|
35 | get normalize () { return normalize; },
|
36 | get threshold () { return threshold; },
|
37 | get modulate () { return modulate; },
|
38 | get tint () { return tint; },
|
39 | get grayscale () { return grayscale; },
|
40 | get crop () { return crop; },
|
41 | get q () { return q; },
|
42 | get b () { return b; },
|
43 | get w () { return w; },
|
44 | get h () { return h; },
|
45 | get s () { return s; }
|
46 | });
|
47 |
|
48 | function getEnv(name, defaultValue) {
|
49 | var _a;
|
50 | return (_a = destr(process.env[name])) != null ? _a : defaultValue;
|
51 | }
|
52 | function cachedPromise(fn) {
|
53 | let p;
|
54 | return (...args) => {
|
55 | if (p) {
|
56 | return p;
|
57 | }
|
58 | p = Promise.resolve(fn(...args));
|
59 | return p;
|
60 | };
|
61 | }
|
62 | class IPXError extends Error {
|
63 | }
|
64 | function createError(message, statusCode) {
|
65 | const err = new IPXError(message);
|
66 | err.statusMessage = "IPX: " + message;
|
67 | err.statusCode = statusCode;
|
68 | return err;
|
69 | }
|
70 |
|
71 | const createFilesystemSource = (options) => {
|
72 | const rootDir = resolve(options.dir);
|
73 | return async (id) => {
|
74 | const fsPath = resolve(join(rootDir, id));
|
75 | if (!isValidPath(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
|
76 | throw createError("Forbidden path:" + id, 403);
|
77 | }
|
78 | let stats;
|
79 | try {
|
80 | stats = await stat(fsPath);
|
81 | } catch (err) {
|
82 | if (err.code === "ENOENT") {
|
83 | throw createError("File not found: " + fsPath, 404);
|
84 | } else {
|
85 | throw createError("File access error for " + fsPath + ":" + err.code, 403);
|
86 | }
|
87 | }
|
88 | if (!stats.isFile()) {
|
89 | throw createError("Path should be a file: " + fsPath, 400);
|
90 | }
|
91 | return {
|
92 | mtime: stats.mtime,
|
93 | maxAge: options.maxAge || 300,
|
94 | getData: cachedPromise(() => readFile(fsPath))
|
95 | };
|
96 | };
|
97 | };
|
98 |
|
99 | const createHTTPSource = (options) => {
|
100 | const httpsAgent = new https.Agent({ keepAlive: true });
|
101 | const httpAgent = new http.Agent({ keepAlive: true });
|
102 | let domains = options.domains || [];
|
103 | if (typeof domains === "string") {
|
104 | domains = domains.split(",").map((s) => s.trim());
|
105 | }
|
106 | const hosts = domains.map((domain) => parseURL(domain, "https://").host);
|
107 | return async (id) => {
|
108 | const parsedUrl = parseURL(id, "https://");
|
109 | if (!parsedUrl.host) {
|
110 | throw createError("Hostname is missing: " + id, 403);
|
111 | }
|
112 | if (!hosts.find((host) => parsedUrl.host === host)) {
|
113 | throw createError("Forbidden host: " + parsedUrl.host, 403);
|
114 | }
|
115 | const response = await fetch(id, {
|
116 | agent: id.startsWith("https") ? httpsAgent : httpAgent
|
117 | });
|
118 | if (!response.ok) {
|
119 | throw createError(response.statusText || "fetch error", response.status || 500);
|
120 | }
|
121 | let maxAge = options.maxAge || 300;
|
122 | const _cacheControl = response.headers.get("cache-control");
|
123 | if (_cacheControl) {
|
124 | const m = _cacheControl.match(/max-age=(\d+)/);
|
125 | if (m && m[1]) {
|
126 | maxAge = parseInt(m[1]);
|
127 | }
|
128 | }
|
129 | let mtime;
|
130 | const _lastModified = response.headers.get("last-modified");
|
131 | if (_lastModified) {
|
132 | mtime = new Date(_lastModified);
|
133 | }
|
134 | return {
|
135 | mtime,
|
136 | maxAge,
|
137 | getData: cachedPromise(() => response.buffer())
|
138 | };
|
139 | };
|
140 | };
|
141 |
|
142 | function VArg(arg) {
|
143 | return destr(arg);
|
144 | }
|
145 | function parseArgs(args, mappers) {
|
146 | const vargs = args.split("_");
|
147 | return mappers.map((v, i) => v(vargs[i]));
|
148 | }
|
149 | function getHandler(key) {
|
150 | return Handlers[key];
|
151 | }
|
152 | function applyHandler(ctx, pipe, handler, argsStr) {
|
153 | const args = handler.args ? parseArgs(argsStr, handler.args) : [];
|
154 | return handler.apply(ctx, pipe, ...args);
|
155 | }
|
156 |
|
157 | const quality = {
|
158 | args: [VArg],
|
159 | order: -1,
|
160 | apply: (context, _pipe, quality2) => {
|
161 | context.quality = quality2;
|
162 | }
|
163 | };
|
164 | const fit = {
|
165 | args: [VArg],
|
166 | order: -1,
|
167 | apply: (context, _pipe, fit2) => {
|
168 | context.fit = fit2;
|
169 | }
|
170 | };
|
171 | const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
|
172 | const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
|
173 | const background = {
|
174 | args: [VArg],
|
175 | order: -1,
|
176 | apply: (context, _pipe, background2) => {
|
177 | if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
|
178 | background2 = "#" + background2;
|
179 | }
|
180 | context.background = background2;
|
181 | }
|
182 | };
|
183 | const width = {
|
184 | args: [VArg],
|
185 | apply: (_context, pipe, width2) => {
|
186 | return pipe.resize(width2, null);
|
187 | }
|
188 | };
|
189 | const height = {
|
190 | args: [VArg],
|
191 | apply: (_context, pipe, height2) => {
|
192 | return pipe.resize(null, height2);
|
193 | }
|
194 | };
|
195 | const resize = {
|
196 | args: [VArg, VArg, VArg],
|
197 | apply: (context, pipe, width2, height2) => {
|
198 | return pipe.resize(width2, height2, {
|
199 | fit: context.fit,
|
200 | background: context.background
|
201 | });
|
202 | }
|
203 | };
|
204 | const trim = {
|
205 | args: [VArg],
|
206 | apply: (_context, pipe, threshold2) => {
|
207 | return pipe.trim(threshold2);
|
208 | }
|
209 | };
|
210 | const extend = {
|
211 | args: [VArg, VArg, VArg, VArg],
|
212 | apply: (context, pipe, top, right, bottom, left) => {
|
213 | return pipe.extend({
|
214 | top,
|
215 | left,
|
216 | bottom,
|
217 | right,
|
218 | background: context.background
|
219 | });
|
220 | }
|
221 | };
|
222 | const extract = {
|
223 | args: [VArg, VArg, VArg, VArg],
|
224 | apply: (context, pipe, top, right, bottom, left) => {
|
225 | return pipe.extend({
|
226 | top,
|
227 | left,
|
228 | bottom,
|
229 | right,
|
230 | background: context.background
|
231 | });
|
232 | }
|
233 | };
|
234 | const rotate = {
|
235 | args: [VArg],
|
236 | apply: (_context, pipe, angel) => {
|
237 | return pipe.rotate(angel);
|
238 | }
|
239 | };
|
240 | const flip = {
|
241 | args: [],
|
242 | apply: (_context, pipe) => {
|
243 | return pipe.flip();
|
244 | }
|
245 | };
|
246 | const flop = {
|
247 | args: [],
|
248 | apply: (_context, pipe) => {
|
249 | return pipe.flop();
|
250 | }
|
251 | };
|
252 | const sharpen = {
|
253 | args: [VArg, VArg, VArg],
|
254 | apply: (_context, pipe, sigma, flat, jagged) => {
|
255 | return pipe.sharpen(sigma, flat, jagged);
|
256 | }
|
257 | };
|
258 | const median = {
|
259 | args: [VArg, VArg, VArg],
|
260 | apply: (_context, pipe, size) => {
|
261 | return pipe.median(size);
|
262 | }
|
263 | };
|
264 | const blur = {
|
265 | args: [VArg, VArg, VArg],
|
266 | apply: (_context, pipe) => {
|
267 | return pipe.blur();
|
268 | }
|
269 | };
|
270 | const flatten = {
|
271 | args: [VArg, VArg, VArg],
|
272 | apply: (context, pipe) => {
|
273 | return pipe.flatten({
|
274 | background: context.background
|
275 | });
|
276 | }
|
277 | };
|
278 | const gamma = {
|
279 | args: [VArg, VArg, VArg],
|
280 | apply: (_context, pipe, gamma2, gammaOut) => {
|
281 | return pipe.gamma(gamma2, gammaOut);
|
282 | }
|
283 | };
|
284 | const negate = {
|
285 | args: [VArg, VArg, VArg],
|
286 | apply: (_context, pipe) => {
|
287 | return pipe.negate();
|
288 | }
|
289 | };
|
290 | const normalize = {
|
291 | args: [VArg, VArg, VArg],
|
292 | apply: (_context, pipe) => {
|
293 | return pipe.normalize();
|
294 | }
|
295 | };
|
296 | const threshold = {
|
297 | args: [VArg],
|
298 | apply: (_context, pipe, threshold2) => {
|
299 | return pipe.threshold(threshold2);
|
300 | }
|
301 | };
|
302 | const modulate = {
|
303 | args: [VArg],
|
304 | apply: (_context, pipe, brightness, saturation, hue) => {
|
305 | return pipe.modulate({
|
306 | brightness,
|
307 | saturation,
|
308 | hue
|
309 | });
|
310 | }
|
311 | };
|
312 | const tint = {
|
313 | args: [VArg],
|
314 | apply: (_context, pipe, rgb) => {
|
315 | return pipe.tint(rgb);
|
316 | }
|
317 | };
|
318 | const grayscale = {
|
319 | args: [VArg],
|
320 | apply: (_context, pipe) => {
|
321 | return pipe.grayscale();
|
322 | }
|
323 | };
|
324 | const crop = extract;
|
325 | const q = quality;
|
326 | const b = background;
|
327 | const w = width;
|
328 | const h = height;
|
329 | const s = resize;
|
330 |
|
331 | const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
|
332 | function createIPX(userOptions) {
|
333 | const defaults = {
|
334 | dir: getEnv("IPX_DIR", "."),
|
335 | domains: getEnv("IPX_DOMAINS", []),
|
336 | alias: getEnv("IPX_ALIAS", {}),
|
337 | sharp: {}
|
338 | };
|
339 | const options = defu(userOptions, defaults);
|
340 | options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [withLeadingSlash(e[0]), e[1]]));
|
341 | const ctx = {
|
342 | sources: {}
|
343 | };
|
344 | if (options.dir) {
|
345 | ctx.sources.filesystem = createFilesystemSource({
|
346 | dir: options.dir
|
347 | });
|
348 | }
|
349 | if (options.domains) {
|
350 | ctx.sources.http = createHTTPSource({
|
351 | domains: options.domains
|
352 | });
|
353 | }
|
354 | return function ipx(id, inputOpts = {}) {
|
355 | if (!id) {
|
356 | throw createError("resource id is missing", 400);
|
357 | }
|
358 | id = hasProtocol(id) ? id : withLeadingSlash(id);
|
359 | for (const base in options.alias) {
|
360 | if (id.startsWith(base)) {
|
361 | id = joinURL(options.alias[base], id.substr(base.length));
|
362 | }
|
363 | }
|
364 | const modifiers = inputOpts.modifiers || {};
|
365 | const getSrc = cachedPromise(() => {
|
366 | const source = inputOpts.source || hasProtocol(id) ? "http" : "filesystem";
|
367 | if (!ctx.sources[source]) {
|
368 | throw createError("Unknown source: " + source, 400);
|
369 | }
|
370 | return ctx.sources[source](id);
|
371 | });
|
372 | const getData = cachedPromise(async () => {
|
373 | const src = await getSrc();
|
374 | const data = await src.getData();
|
375 | const meta = imageMeta(data);
|
376 | const mFormat = modifiers.f || modifiers.format;
|
377 | let format = mFormat || meta.type;
|
378 | if (format === "jpg") {
|
379 | format = "jpeg";
|
380 | }
|
381 | if (meta.type === "svg" && !mFormat) {
|
382 | return {
|
383 | data,
|
384 | format: "svg+xml",
|
385 | meta
|
386 | };
|
387 | }
|
388 | const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
|
389 | if (animated) {
|
390 | format = "webp";
|
391 | }
|
392 | let sharp = Sharp(data, { animated });
|
393 | Object.assign(sharp.options, options.sharp);
|
394 | const handlers = Object.entries(inputOpts.modifiers || {}).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
|
395 | const aKey = (a.handler.order || a.name || "").toString();
|
396 | const bKey = (b.handler.order || b.name || "").toString();
|
397 | return aKey.localeCompare(bKey);
|
398 | });
|
399 | const handlerCtx = {};
|
400 | for (const h of handlers) {
|
401 | sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
|
402 | }
|
403 | if (SUPPORTED_FORMATS.includes(format)) {
|
404 | sharp = sharp.toFormat(format, {
|
405 | quality: handlerCtx.quality,
|
406 | progressive: format === "jpeg"
|
407 | });
|
408 | }
|
409 | const newData = await sharp.toBuffer();
|
410 | return {
|
411 | data: newData,
|
412 | format,
|
413 | meta
|
414 | };
|
415 | });
|
416 | return {
|
417 | src: getSrc,
|
418 | data: getData
|
419 | };
|
420 | };
|
421 | }
|
422 |
|
423 | async function _handleRequest(req, ipx) {
|
424 | const res = {
|
425 | statusCode: 200,
|
426 | statusMessage: "",
|
427 | headers: {},
|
428 | data: null
|
429 | };
|
430 | const url = parseURL(normalizeURL(req.url));
|
431 | const params = parseQuery(url.search);
|
432 | const id = withoutLeadingSlash(decode(url.pathname || params.id));
|
433 | const modifiers = Object.create(null);
|
434 | for (const pKey in params) {
|
435 | if (pKey === "source" || pKey === "id") {
|
436 | continue;
|
437 | }
|
438 | modifiers[pKey] = params[pKey];
|
439 | }
|
440 | const img = ipx(id, {
|
441 | modifiers,
|
442 | source: params.source
|
443 | });
|
444 | const src = await img.src();
|
445 | if (src.mtime) {
|
446 | if (req.headers["if-modified-since"]) {
|
447 | if (new Date(req.headers["if-modified-since"]) >= src.mtime) {
|
448 | res.statusCode = 304;
|
449 | return res;
|
450 | }
|
451 | }
|
452 | res.headers["Last-Modified"] = +src.mtime + "";
|
453 | }
|
454 | if (src.maxAge !== void 0) {
|
455 | res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
|
456 | }
|
457 | const { data, format } = await img.data();
|
458 | const etag = getEtag(data);
|
459 | res.headers.ETag = etag;
|
460 | if (etag && req.headers["if-none-match"] === etag) {
|
461 | res.statusCode = 304;
|
462 | return res;
|
463 | }
|
464 | if (format) {
|
465 | res.headers["Content-Type"] = `image/${format}`;
|
466 | }
|
467 | return res;
|
468 | }
|
469 | function handleRequest(req, ipx) {
|
470 | return _handleRequest(req, ipx).catch((err) => {
|
471 | const statusCode = parseInt(err.statusCode) || 500;
|
472 | const statusMessage = err.statusMessage ? xss(err.statusMessage) : `IPX Error (${statusCode})`;
|
473 | if (process.env.NODE_ENV !== "production" && statusCode === 500) {
|
474 | console.error(err);
|
475 | }
|
476 | return {
|
477 | statusCode,
|
478 | statusMessage,
|
479 | data: statusMessage,
|
480 | headers: {}
|
481 | };
|
482 | });
|
483 | }
|
484 | function createIPXMiddleware(ipx) {
|
485 | return function IPXMiddleware(req, res) {
|
486 | handleRequest({ url: req.url, headers: req.headers }, ipx).then((_res) => {
|
487 | res.statusCode = _res.statusCode;
|
488 | res.statusMessage = _res.statusMessage;
|
489 | for (const name in _res.headers) {
|
490 | res.setHeader(name, _res.headers[name]);
|
491 | }
|
492 | res.end(_res.data);
|
493 | });
|
494 | };
|
495 | }
|
496 |
|
497 | export { createIPX, createIPXMiddleware, handleRequest };
|