UNPKG

14.4 kBJavaScriptView Raw
1'use strict';
2
3Object.defineProperty(exports, '__esModule', { value: true });
4
5const Sharp = require('sharp');
6const defu = require('defu');
7const imageMeta = require('image-meta');
8const ufo = require('ufo');
9const path = require('path');
10const isValidPath = require('is-valid-path');
11const fsExtra = require('fs-extra');
12const destr = require('destr');
13const http = require('http');
14const https = require('https');
15const fetch = require('node-fetch');
16const getEtag = require('etag');
17const xss = require('xss');
18
19function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
20
21const Sharp__default = /*#__PURE__*/_interopDefaultLegacy(Sharp);
22const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
23const imageMeta__default = /*#__PURE__*/_interopDefaultLegacy(imageMeta);
24const isValidPath__default = /*#__PURE__*/_interopDefaultLegacy(isValidPath);
25const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
26const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
27const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
28const fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
29const getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
30const xss__default = /*#__PURE__*/_interopDefaultLegacy(xss);
31
32const Handlers = /*#__PURE__*/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
65function getEnv(name, defaultValue) {
66 var _a;
67 return (_a = destr__default['default'](process.env[name])) != null ? _a : defaultValue;
68}
69function 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}
79class IPXError extends Error {
80}
81function createError(message, statusCode) {
82 const err = new IPXError(message);
83 err.statusMessage = "IPX: " + message;
84 err.statusCode = statusCode;
85 return err;
86}
87
88const 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
116const 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
159function VArg(arg) {
160 return destr__default['default'](arg);
161}
162function parseArgs(args, mappers) {
163 const vargs = args.split("_");
164 return mappers.map((v, i) => v(vargs[i]));
165}
166function getHandler(key) {
167 return Handlers[key];
168}
169function applyHandler(ctx, pipe, handler, argsStr) {
170 const args = handler.args ? parseArgs(argsStr, handler.args) : [];
171 return handler.apply(ctx, pipe, ...args);
172}
173
174const quality = {
175 args: [VArg],
176 order: -1,
177 apply: (context, _pipe, quality2) => {
178 context.quality = quality2;
179 }
180};
181const fit = {
182 args: [VArg],
183 order: -1,
184 apply: (context, _pipe, fit2) => {
185 context.fit = fit2;
186 }
187};
188const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
189const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
190const 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};
200const width = {
201 args: [VArg],
202 apply: (_context, pipe, width2) => {
203 return pipe.resize(width2, null);
204 }
205};
206const height = {
207 args: [VArg],
208 apply: (_context, pipe, height2) => {
209 return pipe.resize(null, height2);
210 }
211};
212const 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};
221const trim = {
222 args: [VArg],
223 apply: (_context, pipe, threshold2) => {
224 return pipe.trim(threshold2);
225 }
226};
227const 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};
239const 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};
251const rotate = {
252 args: [VArg],
253 apply: (_context, pipe, angel) => {
254 return pipe.rotate(angel);
255 }
256};
257const flip = {
258 args: [],
259 apply: (_context, pipe) => {
260 return pipe.flip();
261 }
262};
263const flop = {
264 args: [],
265 apply: (_context, pipe) => {
266 return pipe.flop();
267 }
268};
269const sharpen = {
270 args: [VArg, VArg, VArg],
271 apply: (_context, pipe, sigma, flat, jagged) => {
272 return pipe.sharpen(sigma, flat, jagged);
273 }
274};
275const median = {
276 args: [VArg, VArg, VArg],
277 apply: (_context, pipe, size) => {
278 return pipe.median(size);
279 }
280};
281const blur = {
282 args: [VArg, VArg, VArg],
283 apply: (_context, pipe) => {
284 return pipe.blur();
285 }
286};
287const flatten = {
288 args: [VArg, VArg, VArg],
289 apply: (context, pipe) => {
290 return pipe.flatten({
291 background: context.background
292 });
293 }
294};
295const gamma = {
296 args: [VArg, VArg, VArg],
297 apply: (_context, pipe, gamma2, gammaOut) => {
298 return pipe.gamma(gamma2, gammaOut);
299 }
300};
301const negate = {
302 args: [VArg, VArg, VArg],
303 apply: (_context, pipe) => {
304 return pipe.negate();
305 }
306};
307const normalize = {
308 args: [VArg, VArg, VArg],
309 apply: (_context, pipe) => {
310 return pipe.normalize();
311 }
312};
313const threshold = {
314 args: [VArg],
315 apply: (_context, pipe, threshold2) => {
316 return pipe.threshold(threshold2);
317 }
318};
319const modulate = {
320 args: [VArg],
321 apply: (_context, pipe, brightness, saturation, hue) => {
322 return pipe.modulate({
323 brightness,
324 saturation,
325 hue
326 });
327 }
328};
329const tint = {
330 args: [VArg],
331 apply: (_context, pipe, rgb) => {
332 return pipe.tint(rgb);
333 }
334};
335const grayscale = {
336 args: [VArg],
337 apply: (_context, pipe) => {
338 return pipe.grayscale();
339 }
340};
341const crop = extract;
342const q = quality;
343const b = background;
344const w = width;
345const h = height;
346const s = resize;
347
348const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
349function 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
440async 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}
486function 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}
501function 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
514exports.createIPX = createIPX;
515exports.createIPXMiddleware = createIPXMiddleware;
516exports.handleRequest = handleRequest;