UNPKG

13.2 kBJavaScriptView Raw
1import Sharp from 'sharp';
2import defu from 'defu';
3import imageMeta from 'image-meta';
4import { parseURL, withLeadingSlash, hasProtocol, joinURL, normalizeURL, parseQuery, withoutLeadingSlash, decode } from 'ufo';
5import { resolve, join } from 'path';
6import isValidPath from 'is-valid-path';
7import { stat, readFile } from 'fs-extra';
8import destr from 'destr';
9import http from 'http';
10import https from 'https';
11import fetch from 'node-fetch';
12import getEtag from 'etag';
13import xss from 'xss';
14
15var Handlers = /*#__PURE__*/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
48function getEnv(name, defaultValue) {
49 var _a;
50 return (_a = destr(process.env[name])) != null ? _a : defaultValue;
51}
52function 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}
62class IPXError extends Error {
63}
64function createError(message, statusCode) {
65 const err = new IPXError(message);
66 err.statusMessage = "IPX: " + message;
67 err.statusCode = statusCode;
68 return err;
69}
70
71const 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
99const 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
142function VArg(arg) {
143 return destr(arg);
144}
145function parseArgs(args, mappers) {
146 const vargs = args.split("_");
147 return mappers.map((v, i) => v(vargs[i]));
148}
149function getHandler(key) {
150 return Handlers[key];
151}
152function applyHandler(ctx, pipe, handler, argsStr) {
153 const args = handler.args ? parseArgs(argsStr, handler.args) : [];
154 return handler.apply(ctx, pipe, ...args);
155}
156
157const quality = {
158 args: [VArg],
159 order: -1,
160 apply: (context, _pipe, quality2) => {
161 context.quality = quality2;
162 }
163};
164const fit = {
165 args: [VArg],
166 order: -1,
167 apply: (context, _pipe, fit2) => {
168 context.fit = fit2;
169 }
170};
171const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
172const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
173const 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};
183const width = {
184 args: [VArg],
185 apply: (_context, pipe, width2) => {
186 return pipe.resize(width2, null);
187 }
188};
189const height = {
190 args: [VArg],
191 apply: (_context, pipe, height2) => {
192 return pipe.resize(null, height2);
193 }
194};
195const 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};
204const trim = {
205 args: [VArg],
206 apply: (_context, pipe, threshold2) => {
207 return pipe.trim(threshold2);
208 }
209};
210const 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};
222const 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};
234const rotate = {
235 args: [VArg],
236 apply: (_context, pipe, angel) => {
237 return pipe.rotate(angel);
238 }
239};
240const flip = {
241 args: [],
242 apply: (_context, pipe) => {
243 return pipe.flip();
244 }
245};
246const flop = {
247 args: [],
248 apply: (_context, pipe) => {
249 return pipe.flop();
250 }
251};
252const sharpen = {
253 args: [VArg, VArg, VArg],
254 apply: (_context, pipe, sigma, flat, jagged) => {
255 return pipe.sharpen(sigma, flat, jagged);
256 }
257};
258const median = {
259 args: [VArg, VArg, VArg],
260 apply: (_context, pipe, size) => {
261 return pipe.median(size);
262 }
263};
264const blur = {
265 args: [VArg, VArg, VArg],
266 apply: (_context, pipe) => {
267 return pipe.blur();
268 }
269};
270const flatten = {
271 args: [VArg, VArg, VArg],
272 apply: (context, pipe) => {
273 return pipe.flatten({
274 background: context.background
275 });
276 }
277};
278const gamma = {
279 args: [VArg, VArg, VArg],
280 apply: (_context, pipe, gamma2, gammaOut) => {
281 return pipe.gamma(gamma2, gammaOut);
282 }
283};
284const negate = {
285 args: [VArg, VArg, VArg],
286 apply: (_context, pipe) => {
287 return pipe.negate();
288 }
289};
290const normalize = {
291 args: [VArg, VArg, VArg],
292 apply: (_context, pipe) => {
293 return pipe.normalize();
294 }
295};
296const threshold = {
297 args: [VArg],
298 apply: (_context, pipe, threshold2) => {
299 return pipe.threshold(threshold2);
300 }
301};
302const modulate = {
303 args: [VArg],
304 apply: (_context, pipe, brightness, saturation, hue) => {
305 return pipe.modulate({
306 brightness,
307 saturation,
308 hue
309 });
310 }
311};
312const tint = {
313 args: [VArg],
314 apply: (_context, pipe, rgb) => {
315 return pipe.tint(rgb);
316 }
317};
318const grayscale = {
319 args: [VArg],
320 apply: (_context, pipe) => {
321 return pipe.grayscale();
322 }
323};
324const crop = extract;
325const q = quality;
326const b = background;
327const w = width;
328const h = height;
329const s = resize;
330
331const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
332function 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
423async 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}
469function 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}
484function 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
497export { createIPX, createIPXMiddleware, handleRequest };