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