UNPKG

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