ipx
Version:
High performance, secure and easy-to-use image optimizer.
779 lines (766 loc) • 21.7 kB
JavaScript
const defu = require('defu');
const ufo = require('ufo');
const h3 = require('h3');
const imageMeta = require('image-meta');
const destr = require('destr');
const acceptNegotiator = require('@fastify/accept-negotiator');
const getEtag = require('etag');
const ofetch = require('ofetch');
const pathe = require('pathe');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
const destr__default = /*#__PURE__*/_interopDefaultCompat(destr);
const getEtag__default = /*#__PURE__*/_interopDefaultCompat(getEtag);
const Handlers = {
__proto__: null,
get b () { return b; },
get background () { return background; },
get blur () { return blur; },
get crop () { return crop; },
get enlarge () { return enlarge; },
get extend () { return extend; },
get extract () { return extract; },
get fit () { return fit; },
get flatten () { return flatten; },
get flip () { return flip; },
get flop () { return flop; },
get gamma () { return gamma; },
get grayscale () { return grayscale; },
get h () { return h; },
get height () { return height; },
get kernel () { return kernel; },
get median () { return median; },
get modulate () { return modulate; },
get negate () { return negate; },
get normalize () { return normalize; },
get pos () { return pos; },
get position () { return position; },
get q () { return q; },
get quality () { return quality; },
get resize () { return resize; },
get rotate () { return rotate; },
get s () { return s; },
get sharpen () { return sharpen; },
get threshold () { return threshold; },
get tint () { return tint; },
get trim () { return trim; },
get w () { return w; },
get width () { return width; }
};
function VArg(argument) {
return destr__default(argument);
}
function parseArgs(arguments_, mappers) {
const vargs = arguments_.split("_");
return mappers.map((v, index) => v(vargs[index]));
}
function getHandler(key) {
return Handlers[key];
}
function applyHandler(context, pipe, handler, argumentsString) {
const arguments_ = handler.args ? parseArgs(argumentsString, handler.args) : [];
return handler.apply(context, pipe, ...arguments_);
}
function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
let { width, height } = desiredDimensions;
if (sourceDimensions.width && width > sourceDimensions.width) {
width = sourceDimensions.width;
height = Math.round(sourceDimensions.width / desiredAspectRatio);
}
if (sourceDimensions.height && height > sourceDimensions.height) {
height = sourceDimensions.height;
width = Math.round(sourceDimensions.height * desiredAspectRatio);
}
return { width, height };
}
const quality = {
args: [VArg],
order: -1,
apply: (context, _pipe, quality2) => {
context.quality = quality2;
}
};
const fit = {
args: [VArg],
order: -1,
apply: (context, _pipe, fit2) => {
context.fit = fit2;
}
};
const position = {
args: [VArg],
order: -1,
apply: (context, _pipe, position2) => {
context.position = position2;
}
};
const HEX_RE = /^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
const SHORTHEX_RE = /^([\da-f])([\da-f])([\da-f])$/i;
const background = {
args: [VArg],
order: -1,
apply: (context, _pipe, background2) => {
background2 = String(background2);
if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
background2 = "#" + background2;
}
context.background = background2;
}
};
const enlarge = {
args: [],
apply: (context) => {
context.enlarge = true;
}
};
const kernel = {
args: [VArg],
apply: (context, _pipe, kernel2) => {
context.kernel = kernel2;
}
};
const width = {
args: [VArg],
apply: (context, pipe, width2) => {
return pipe.resize(width2, void 0, {
withoutEnlargement: !context.enlarge
});
}
};
const height = {
args: [VArg],
apply: (context, pipe, height2) => {
return pipe.resize(void 0, height2, {
withoutEnlargement: !context.enlarge
});
}
};
const resize = {
args: [VArg, VArg, VArg],
apply: (context, pipe, size) => {
let [width2, height2] = String(size).split("x").map(Number);
if (!width2) {
return;
}
if (!height2) {
height2 = width2;
}
if (!context.enlarge) {
const clamped = clampDimensionsPreservingAspectRatio(context.meta, {
width: width2,
height: height2
});
width2 = clamped.width;
height2 = clamped.height;
}
return pipe.resize(width2, height2, {
fit: context.fit,
position: context.position,
background: context.background,
kernel: context.kernel
});
}
};
const trim = {
args: [VArg],
apply: (_context, pipe, threshold2) => {
return pipe.trim(threshold2);
}
};
const extend = {
args: [VArg, VArg, VArg, VArg],
apply: (context, pipe, top, right, bottom, left) => {
return pipe.extend({
top,
left,
bottom,
right,
background: context.background
});
}
};
const extract = {
args: [VArg, VArg, VArg, VArg],
apply: (_context, pipe, left, top, width2, height2) => {
return pipe.extract({
left,
top,
width: width2,
height: height2
});
}
};
const rotate = {
args: [VArg],
apply: (context, pipe, angel) => {
return pipe.rotate(angel, {
background: context.background
});
}
};
const flip = {
args: [],
apply: (_context, pipe) => {
return pipe.flip();
}
};
const flop = {
args: [],
apply: (_context, pipe) => {
return pipe.flop();
}
};
const sharpen = {
args: [VArg, VArg, VArg],
apply: (_context, pipe, sigma, flat, jagged) => {
return pipe.sharpen(sigma, flat, jagged);
}
};
const median = {
args: [VArg, VArg, VArg],
apply: (_context, pipe, size) => {
return pipe.median(size);
}
};
const blur = {
args: [VArg, VArg, VArg],
apply: (_context, pipe, sigma) => {
return pipe.blur(sigma);
}
};
const flatten = {
args: [VArg, VArg, VArg],
apply: (context, pipe) => {
return pipe.flatten({
background: context.background
});
}
};
const gamma = {
args: [VArg, VArg, VArg],
apply: (_context, pipe, gamma2, gammaOut) => {
return pipe.gamma(gamma2, gammaOut);
}
};
const negate = {
args: [VArg, VArg, VArg],
apply: (_context, pipe) => {
return pipe.negate();
}
};
const normalize = {
args: [VArg, VArg, VArg],
apply: (_context, pipe) => {
return pipe.normalize();
}
};
const threshold = {
args: [VArg],
apply: (_context, pipe, threshold2) => {
return pipe.threshold(threshold2);
}
};
const modulate = {
args: [VArg],
apply: (_context, pipe, brightness, saturation, hue) => {
return pipe.modulate({
brightness,
saturation,
hue
});
}
};
const tint = {
args: [VArg],
apply: (_context, pipe, rgb) => {
return pipe.tint(rgb);
}
};
const grayscale = {
args: [VArg],
apply: (_context, pipe) => {
return pipe.grayscale();
}
};
const crop = extract;
const q = quality;
const b = background;
const w = width;
const h = height;
const s = resize;
const pos = position;
function getEnv(name) {
return name in process.env ? destr__default(process.env[name]) : void 0;
}
function cachedPromise(function_) {
let p;
return (...arguments_) => {
if (p) {
return p;
}
p = Promise.resolve(function_(...arguments_));
return p;
};
}
const SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
"jpeg",
"png",
"webp",
"avif",
"tiff",
"heif",
"gif",
"heic"
]);
function createIPX(userOptions) {
const options = defu.defu(userOptions, {
alias: getEnv("IPX_ALIAS") || {},
maxAge: getEnv("IPX_MAX_AGE") ?? 60,
sharpOptions: {
jpegProgressive: true
}
});
options.alias = Object.fromEntries(
Object.entries(options.alias || {}).map((e) => [
ufo.withLeadingSlash(e[0]),
e[1]
])
);
const getSharp = cachedPromise(async () => {
return await import('sharp').then(
(r) => r.default || r
);
});
const getSVGO = cachedPromise(async () => {
const { optimize } = await import('svgo');
return { optimize };
});
return function ipx(id, modifiers = {}, opts = {}) {
if (!id) {
throw h3.createError({
statusCode: 400,
statusText: `IPX_MISSING_ID`,
message: `Resource id is missing`
});
}
id = ufo.hasProtocol(id) ? id : ufo.withLeadingSlash(id);
for (const base in options.alias) {
if (id.startsWith(base)) {
id = ufo.joinURL(options.alias[base], id.slice(base.length));
}
}
const storage = ufo.hasProtocol(id) ? options.httpStorage || options.storage : options.storage || options.httpStorage;
if (!storage) {
throw h3.createError({
statusCode: 500,
statusText: `IPX_NO_STORAGE`,
message: "No storage configured!"
});
}
const getSourceMeta = cachedPromise(async () => {
const sourceMeta = await storage.getMeta(id, opts);
if (!sourceMeta) {
throw h3.createError({
statusCode: 404,
statusText: `IPX_RESOURCE_NOT_FOUND`,
message: `Resource not found: ${id}`
});
}
const _maxAge = sourceMeta.maxAge ?? options.maxAge;
return {
maxAge: typeof _maxAge === "string" ? Number.parseInt(_maxAge) : _maxAge,
mtime: sourceMeta.mtime ? new Date(sourceMeta.mtime) : void 0
};
});
const getSourceData = cachedPromise(async () => {
const sourceData = await storage.getData(id, opts);
if (!sourceData) {
throw h3.createError({
statusCode: 404,
statusText: `IPX_RESOURCE_NOT_FOUND`,
message: `Resource not found: ${id}`
});
}
return Buffer.from(sourceData);
});
const process = cachedPromise(async () => {
const sourceData = await getSourceData();
let imageMeta$1;
try {
imageMeta$1 = imageMeta.imageMeta(sourceData);
} catch {
throw h3.createError({
statusCode: 400,
statusText: `IPX_INVALID_IMAGE`,
message: `Cannot parse image metadata: ${id}`
});
}
let mFormat = modifiers.f || modifiers.format;
if (mFormat === "jpg") {
mFormat = "jpeg";
}
const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
if (imageMeta$1.type === "svg" && !mFormat) {
if (options.svgo === false) {
return {
data: sourceData,
format: "svg+xml",
meta: imageMeta$1
};
} else {
const { optimize } = await getSVGO();
const svg = optimize(sourceData.toString("utf8"), {
...options.svgo,
plugins: ["removeScripts", ...options.svgo?.plugins || []]
}).data;
return {
data: svg,
format: "svg+xml",
meta: imageMeta$1
};
}
}
const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
const Sharp = await getSharp();
let sharp = Sharp(sourceData, { animated, ...options.sharpOptions });
Object.assign(
sharp.options,
options.sharpOptions
);
const handlers = Object.entries(modifiers).map(([name, arguments_]) => ({
handler: getHandler(name),
name,
args: arguments_
})).filter((h) => h.handler).sort((a, b) => {
const aKey = (a.handler.order || a.name || "").toString();
const bKey = (b.handler.order || b.name || "").toString();
return aKey.localeCompare(bKey);
});
const handlerContext = { meta: imageMeta$1 };
for (const h of handlers) {
sharp = applyHandler(handlerContext, sharp, h.handler, h.args) || sharp;
}
if (SUPPORTED_FORMATS.has(format || "")) {
sharp = sharp.toFormat(format, {
quality: handlerContext.quality
});
}
const processedImage = await sharp.toBuffer();
return {
data: processedImage,
format,
meta: imageMeta$1
};
});
return {
getSourceMeta,
process
};
};
}
const MODIFIER_SEP = /[&,]/g;
const MODIFIER_VAL_SEP = /[:=_]/;
function createIPXH3Handler(ipx) {
const _handler = async (event) => {
const [modifiersString = "", ...idSegments] = event.path.slice(
1
/* leading slash */
).split("/");
const id = safeString(ufo.decode(idSegments.join("/")));
if (!modifiersString) {
throw h3.createError({
statusCode: 400,
statusText: `IPX_MISSING_MODIFIERS`,
message: `Modifiers are missing: ${id}`
});
}
if (!id || id === "/") {
throw h3.createError({
statusCode: 400,
statusText: `IPX_MISSING_ID`,
message: `Resource id is missing: ${event.path}`
});
}
const modifiers = /* @__PURE__ */ Object.create(null);
if (modifiersString !== "_") {
for (const p of modifiersString.split(MODIFIER_SEP)) {
const [key, ...values] = p.split(MODIFIER_VAL_SEP);
modifiers[safeString(key)] = values.map((v) => safeString(ufo.decode(v))).join("_");
}
}
const mFormat = modifiers.f || modifiers.format;
if (mFormat === "auto") {
const acceptHeader = h3.getRequestHeader(event, "accept") || "";
const animated = modifiers.animated ?? modifiers.a;
const autoFormat = autoDetectFormat(
acceptHeader,
// #234 "animated" param adds {animated: ''} to the modifiers
// TODO: fix modifiers to normalized to boolean
!!animated || animated === ""
);
delete modifiers.f;
delete modifiers.format;
if (autoFormat) {
modifiers.format = autoFormat;
h3.appendResponseHeader(event, "vary", "Accept");
}
}
const img = ipx(id, modifiers);
const sourceMeta = await img.getSourceMeta();
sendResponseHeaderIfNotSet(
event,
"content-security-policy",
"default-src 'none'"
);
if (sourceMeta.mtime) {
sendResponseHeaderIfNotSet(
event,
"last-modified",
sourceMeta.mtime.toUTCString()
);
const _ifModifiedSince = h3.getRequestHeader(event, "if-modified-since");
if (_ifModifiedSince && new Date(_ifModifiedSince) >= sourceMeta.mtime) {
h3.setResponseStatus(event, 304);
return h3.send(event);
}
}
const { data, format } = await img.process();
if (typeof sourceMeta.maxAge === "number") {
sendResponseHeaderIfNotSet(
event,
"cache-control",
`max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`
);
}
const etag = getEtag__default(data);
sendResponseHeaderIfNotSet(event, "etag", etag);
if (etag && h3.getRequestHeader(event, "if-none-match") === etag) {
h3.setResponseStatus(event, 304);
return h3.send(event);
}
if (format) {
sendResponseHeaderIfNotSet(event, "content-type", `image/${format}`);
}
return data;
};
return h3.defineEventHandler(async (event) => {
try {
return await _handler(event);
} catch (_error) {
const error = h3.createError(_error);
h3.setResponseStatus(event, error.statusCode, error.statusMessage);
return {
error: {
message: `[${error.statusCode}] [${error.statusMessage || "IPX_ERROR"}] ${error.message}`
}
};
}
});
}
function createIPXH3App(ipx) {
const app = h3.createApp({ debug: true });
app.use(createIPXH3Handler(ipx));
return app;
}
function createIPXWebServer(ipx) {
return h3.toWebHandler(createIPXH3App(ipx));
}
function createIPXNodeServer(ipx) {
return h3.toNodeListener(createIPXH3App(ipx));
}
function createIPXPlainServer(ipx) {
return h3.toPlainHandler(createIPXH3App(ipx));
}
function sendResponseHeaderIfNotSet(event, name, value) {
if (!h3.getResponseHeader(event, name)) {
h3.setResponseHeader(event, name, value);
}
}
function autoDetectFormat(acceptHeader, animated) {
if (animated) {
const acceptMime2 = acceptNegotiator.negotiate(acceptHeader, ["image/webp", "image/gif"]);
return acceptMime2?.split("/")[1] || "gif";
}
const acceptMime = acceptNegotiator.negotiate(acceptHeader, [
"image/avif",
"image/webp",
"image/jpeg",
"image/png",
"image/tiff",
"image/heif",
"image/gif"
]);
return acceptMime?.split("/")[1] || "jpeg";
}
function safeString(input) {
return JSON.stringify(input).replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, '"');
}
const HTTP_RE = /^https?:\/\//;
function ipxHttpStorage(_options = {}) {
const allowAllDomains = _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false;
let _domains = _options.domains || getEnv("IPX_HTTP_DOMAINS") || [];
const defaultMaxAge = _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300;
const fetchOptions = _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {};
if (typeof _domains === "string") {
_domains = _domains.split(",").map((s) => s.trim());
}
const domains = new Set(
_domains.map((d) => {
if (!HTTP_RE.test(d)) {
d = "http://" + d;
}
return new URL(d).hostname;
}).filter(Boolean)
);
function validateId(id) {
const url = new URL(decodeURIComponent(id));
if (!url.hostname) {
throw h3.createError({
statusCode: 403,
statusText: `IPX_MISSING_HOSTNAME`,
message: `Hostname is missing: ${id}`
});
}
if (!allowAllDomains && !domains.has(url.hostname)) {
throw h3.createError({
statusCode: 403,
statusText: `IPX_FORBIDDEN_HOST`,
message: `Forbidden host: ${url.hostname}`
});
}
return url.toString();
}
function parseResponse(response) {
let maxAge = defaultMaxAge;
if (_options.ignoreCacheControl !== true) {
const _cacheControl = response.headers.get("cache-control");
if (_cacheControl) {
const m = _cacheControl.match(/max-age=(\d+)/);
if (m && m[1]) {
maxAge = Number.parseInt(m[1]);
}
}
}
let mtime;
const _lastModified = response.headers.get("last-modified");
if (_lastModified) {
mtime = new Date(_lastModified);
}
return { maxAge, mtime };
}
return {
name: "ipx:http",
async getMeta(id) {
const url = validateId(id);
try {
const response = await ofetch.ofetch.raw(url, {
...fetchOptions,
method: "HEAD"
});
const { maxAge, mtime } = parseResponse(response);
return { mtime, maxAge };
} catch {
return {};
}
},
async getData(id) {
const url = validateId(id);
const response = await ofetch.ofetch(url, {
...fetchOptions,
method: "GET",
responseType: "arrayBuffer"
});
return response;
}
};
}
function ipxFSStorage(_options = {}) {
const dirs = resolveDirs(_options.dir);
const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE");
const _getFS = cachedPromise(
() => import('node:fs/promises').catch(() => {
throw h3.createError({
statusCode: 500,
statusText: `IPX_FILESYSTEM_ERROR`,
message: `Failed to resolve filesystem module`
});
})
);
const resolveFile = async (id) => {
const fs = await _getFS();
for (const dir of dirs) {
const filePath = pathe.join(dir, id);
if (!isValidPath(filePath) || !filePath.startsWith(dir + "/")) {
throw h3.createError({
statusCode: 403,
statusText: `IPX_FORBIDDEN_PATH`,
message: `Forbidden path: ${id}`
});
}
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
continue;
}
return {
stats,
read: () => fs.readFile(filePath)
};
} catch (error) {
if (error.code === "ENOENT") {
continue;
}
throw h3.createError({
statusCode: 403,
statusText: `IPX_FORBIDDEN_FILE`,
message: `Cannot access file: ${id}`
});
}
}
throw h3.createError({
statusCode: 404,
statusText: `IPX_FILE_NOT_FOUND`,
message: `File not found: ${id}`
});
};
return {
name: "ipx:node-fs",
async getMeta(id) {
const { stats } = await resolveFile(id);
return {
mtime: stats.mtime,
maxAge
};
},
async getData(id) {
const { read } = await resolveFile(id);
return read();
}
};
}
const isWindows = process.platform === "win32";
function isValidPath(fp) {
if (isWindows) {
fp = fp.slice(pathe.parse(fp).root.length);
}
if (/["*:<>?|]/.test(fp)) {
return false;
}
return true;
}
function resolveDirs(dirs) {
if (!dirs || !Array.isArray(dirs)) {
const dir = pathe.resolve(dirs || getEnv("IPX_FS_DIR") || ".");
return [dir];
}
return dirs.map((dirs2) => pathe.resolve(dirs2));
}
exports.createIPX = createIPX;
exports.createIPXH3App = createIPXH3App;
exports.createIPXH3Handler = createIPXH3Handler;
exports.createIPXNodeServer = createIPXNodeServer;
exports.createIPXPlainServer = createIPXPlainServer;
exports.createIPXWebServer = createIPXWebServer;
exports.ipxFSStorage = ipxFSStorage;
exports.ipxHttpStorage = ipxHttpStorage;
;