1 | #!/usr/bin/env node
|
2 |
|
3 | 'use strict';
|
4 |
|
5 | const consola = require('consola');
|
6 | const listhen = require('listhen');
|
7 | const Sharp = require('sharp');
|
8 | const defu = require('defu');
|
9 | const imageMeta = require('image-meta');
|
10 | const ufo = require('ufo');
|
11 | const path = require('path');
|
12 | const isValidPath = require('is-valid-path');
|
13 | const fsExtra = require('fs-extra');
|
14 | const destr = require('destr');
|
15 | const http = require('http');
|
16 | const https = require('https');
|
17 | const fetch = require('node-fetch');
|
18 | const getEtag = require('etag');
|
19 | const xss = require('xss');
|
20 |
|
21 | function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
22 |
|
23 | const consola__default = _interopDefaultLegacy(consola);
|
24 | const Sharp__default = _interopDefaultLegacy(Sharp);
|
25 | const defu__default = _interopDefaultLegacy(defu);
|
26 | const imageMeta__default = _interopDefaultLegacy(imageMeta);
|
27 | const isValidPath__default = _interopDefaultLegacy(isValidPath);
|
28 | const destr__default = _interopDefaultLegacy(destr);
|
29 | const http__default = _interopDefaultLegacy(http);
|
30 | const https__default = _interopDefaultLegacy(https);
|
31 | const fetch__default = _interopDefaultLegacy(fetch);
|
32 | const getEtag__default = _interopDefaultLegacy(getEtag);
|
33 | const xss__default = _interopDefaultLegacy(xss);
|
34 |
|
35 | const Handlers = 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 |
|
68 | function getEnv(name, defaultValue) {
|
69 | var _a;
|
70 | return (_a = destr__default['default'](process.env[name])) != null ? _a : defaultValue;
|
71 | }
|
72 | function 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 | }
|
82 | class IPXError extends Error {
|
83 | }
|
84 | function createError(message, statusCode) {
|
85 | const err = new IPXError(message);
|
86 | err.statusMessage = "IPX: " + message;
|
87 | err.statusCode = statusCode;
|
88 | return err;
|
89 | }
|
90 |
|
91 | const 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 |
|
119 | const 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 |
|
162 | function VArg(arg) {
|
163 | return destr__default['default'](arg);
|
164 | }
|
165 | function parseArgs(args, mappers) {
|
166 | const vargs = args.split("_");
|
167 | return mappers.map((v, i) => v(vargs[i]));
|
168 | }
|
169 | function getHandler(key) {
|
170 | return Handlers[key];
|
171 | }
|
172 | function applyHandler(ctx, pipe, handler, argsStr) {
|
173 | const args = handler.args ? parseArgs(argsStr, handler.args) : [];
|
174 | return handler.apply(ctx, pipe, ...args);
|
175 | }
|
176 |
|
177 | const quality = {
|
178 | args: [VArg],
|
179 | order: -1,
|
180 | apply: (context, _pipe, quality2) => {
|
181 | context.quality = quality2;
|
182 | }
|
183 | };
|
184 | const fit = {
|
185 | args: [VArg],
|
186 | order: -1,
|
187 | apply: (context, _pipe, fit2) => {
|
188 | context.fit = fit2;
|
189 | }
|
190 | };
|
191 | const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
|
192 | const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
|
193 | const 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 | };
|
203 | const width = {
|
204 | args: [VArg],
|
205 | apply: (_context, pipe, width2) => {
|
206 | return pipe.resize(width2, null);
|
207 | }
|
208 | };
|
209 | const height = {
|
210 | args: [VArg],
|
211 | apply: (_context, pipe, height2) => {
|
212 | return pipe.resize(null, height2);
|
213 | }
|
214 | };
|
215 | const 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 | };
|
224 | const trim = {
|
225 | args: [VArg],
|
226 | apply: (_context, pipe, threshold2) => {
|
227 | return pipe.trim(threshold2);
|
228 | }
|
229 | };
|
230 | const 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 | };
|
242 | const 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 | };
|
254 | const rotate = {
|
255 | args: [VArg],
|
256 | apply: (_context, pipe, angel) => {
|
257 | return pipe.rotate(angel);
|
258 | }
|
259 | };
|
260 | const flip = {
|
261 | args: [],
|
262 | apply: (_context, pipe) => {
|
263 | return pipe.flip();
|
264 | }
|
265 | };
|
266 | const flop = {
|
267 | args: [],
|
268 | apply: (_context, pipe) => {
|
269 | return pipe.flop();
|
270 | }
|
271 | };
|
272 | const sharpen = {
|
273 | args: [VArg, VArg, VArg],
|
274 | apply: (_context, pipe, sigma, flat, jagged) => {
|
275 | return pipe.sharpen(sigma, flat, jagged);
|
276 | }
|
277 | };
|
278 | const median = {
|
279 | args: [VArg, VArg, VArg],
|
280 | apply: (_context, pipe, size) => {
|
281 | return pipe.median(size);
|
282 | }
|
283 | };
|
284 | const blur = {
|
285 | args: [VArg, VArg, VArg],
|
286 | apply: (_context, pipe) => {
|
287 | return pipe.blur();
|
288 | }
|
289 | };
|
290 | const flatten = {
|
291 | args: [VArg, VArg, VArg],
|
292 | apply: (context, pipe) => {
|
293 | return pipe.flatten({
|
294 | background: context.background
|
295 | });
|
296 | }
|
297 | };
|
298 | const gamma = {
|
299 | args: [VArg, VArg, VArg],
|
300 | apply: (_context, pipe, gamma2, gammaOut) => {
|
301 | return pipe.gamma(gamma2, gammaOut);
|
302 | }
|
303 | };
|
304 | const negate = {
|
305 | args: [VArg, VArg, VArg],
|
306 | apply: (_context, pipe) => {
|
307 | return pipe.negate();
|
308 | }
|
309 | };
|
310 | const normalize = {
|
311 | args: [VArg, VArg, VArg],
|
312 | apply: (_context, pipe) => {
|
313 | return pipe.normalize();
|
314 | }
|
315 | };
|
316 | const threshold = {
|
317 | args: [VArg],
|
318 | apply: (_context, pipe, threshold2) => {
|
319 | return pipe.threshold(threshold2);
|
320 | }
|
321 | };
|
322 | const modulate = {
|
323 | args: [VArg],
|
324 | apply: (_context, pipe, brightness, saturation, hue) => {
|
325 | return pipe.modulate({
|
326 | brightness,
|
327 | saturation,
|
328 | hue
|
329 | });
|
330 | }
|
331 | };
|
332 | const tint = {
|
333 | args: [VArg],
|
334 | apply: (_context, pipe, rgb) => {
|
335 | return pipe.tint(rgb);
|
336 | }
|
337 | };
|
338 | const grayscale = {
|
339 | args: [VArg],
|
340 | apply: (_context, pipe) => {
|
341 | return pipe.grayscale();
|
342 | }
|
343 | };
|
344 | const crop = extract;
|
345 | const q = quality;
|
346 | const b = background;
|
347 | const w = width;
|
348 | const h = height;
|
349 | const s = resize;
|
350 |
|
351 | const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
|
352 | function 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 |
|
443 | async 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 | }
|
489 | function 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 | }
|
504 | function 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 |
|
517 | async function main() {
|
518 | const ipx = createIPX({});
|
519 | const middleware = createIPXMiddleware(ipx);
|
520 | await listhen.listen(middleware, {
|
521 | clipboard: false
|
522 | });
|
523 | }
|
524 | main().catch((err) => {
|
525 | consola__default['default'].error(err);
|
526 | process.exit(1);
|
527 | });
|