const crypto = require("node:crypto"); const fs = require("node:fs"); const sharp = require("sharp"); const SUPPORTED_FORMATS = Object.freeze(new Set(["jpeg", "png", "webp"])); const CONTENT_TYPES = Object.freeze({ jpeg: "image/jpeg", png: "image/png", webp: "image/webp", }); const BLACK = Object.freeze({ r: 0, g: 0, b: 0, alpha: 1 }); const DEFAULT_IMAGE_DERIVATIVE_OPTIONS = Object.freeze({ display: Object.freeze({ maxWidth: 1920, maxHeight: 1080, quality: 82 }), thumbnail: Object.freeze({ maxWidth: 480, maxHeight: 270, quality: 72 }), }); class ImageProcessingError extends Error { constructor(code, message, { statusCode = 422, details, cause } = {}) { super(message, cause ? { cause } : undefined); this.name = "ImageProcessingError"; this.code = code; this.statusCode = statusCode; if (details !== undefined) { this.details = details; } } } function requireInteger(value, name, { min, max } = {}) { if (!Number.isSafeInteger(value)) { throw new TypeError(`${name} must be an integer`); } if (min !== undefined && value < min) { throw new RangeError(`${name} must be at least ${min}`); } if (max !== undefined && value > max) { throw new RangeError(`${name} must be at most ${max}`); } return value; } function normalizeTargetOptions(target, name, defaults) { if ( target !== undefined && (target === null || typeof target !== "object" || Array.isArray(target)) ) { throw new TypeError(`${name} image options must be an object`); } const configured = { ...defaults, ...target }; return Object.freeze({ maxWidth: requireInteger(configured.maxWidth, `${name}.maxWidth`, { min: 1 }), maxHeight: requireInteger(configured.maxHeight, `${name}.maxHeight`, { min: 1 }), quality: requireInteger(configured.quality, `${name}.quality`, { min: 1, max: 100, }), }); } function normalizeOptions(options) { if (options === null || typeof options !== "object" || Array.isArray(options)) { throw new TypeError("Image processing options are required"); } return Object.freeze({ maxInputPixels: requireInteger( options.maxInputPixels, "maxInputPixels", { min: 1 }, ), display: normalizeTargetOptions( options.display, "display", DEFAULT_IMAGE_DERIVATIVE_OPTIONS.display, ), thumbnail: normalizeTargetOptions( options.thumbnail, "thumbnail", DEFAULT_IMAGE_DERIVATIVE_OPTIONS.thumbnail, ), }); } function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } async function hashFile(filename) { const hash = crypto.createHash("sha256"); let byteSize = 0; try { for await (const chunk of fs.createReadStream(filename)) { byteSize += chunk.length; hash.update(chunk); } } catch (error) { throw new ImageProcessingError( "IMAGE_INPUT_UNREADABLE", "The uploaded image could not be read", { statusCode: 422, cause: error }, ); } return { byteSize, sha256: hash.digest("hex") }; } async function describeInput(input) { if (Buffer.isBuffer(input)) { return { sharpInput: input, byteSize: input.length, sha256: sha256(input), }; } if (typeof input !== "string" || input.length === 0) { throw new ImageProcessingError( "INVALID_IMAGE_INPUT", "Image input must be a Buffer or temporary file path", { statusCode: 400 }, ); } let stats; try { stats = await fs.promises.stat(input); } catch (error) { throw new ImageProcessingError( "IMAGE_INPUT_UNREADABLE", "The uploaded image could not be read", { statusCode: 422, cause: error }, ); } if (!stats.isFile()) { throw new ImageProcessingError( "INVALID_IMAGE_INPUT", "Image input path must identify a regular file", { statusCode: 400 }, ); } return { sharpInput: input, ...(await hashFile(input)), }; } function decodeError(error, maxInputPixels) { if (error instanceof ImageProcessingError) { return error; } if (/pixel limit/i.test(error?.message || "")) { return new ImageProcessingError( "IMAGE_PIXEL_LIMIT_EXCEEDED", "The uploaded image exceeds the configured decoded-pixel limit", { statusCode: 413, details: { maxInputPixels }, cause: error, }, ); } return new ImageProcessingError( "MALFORMED_IMAGE", "The uploaded file is not a valid decodable image", { statusCode: 422, cause: error }, ); } async function readMetadata(input, maxInputPixels) { try { return await sharp(input, { animated: true, failOn: "error", limitInputPixels: maxInputPixels, }).metadata(); } catch (error) { throw decodeError(error, maxInputPixels); } } function validateMetadata(metadata) { const pages = metadata.pages ?? 1; if (pages > 1) { const hasAnimationTiming = metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0); throw new ImageProcessingError( hasAnimationTiming ? "ANIMATED_IMAGE_NOT_SUPPORTED" : "MULTIPAGE_IMAGE_NOT_SUPPORTED", hasAnimationTiming ? "Animated images are not supported" : "Multipage images are not supported", { statusCode: 422, details: { pages } }, ); } if (!SUPPORTED_FORMATS.has(metadata.format)) { throw new ImageProcessingError( "UNSUPPORTED_IMAGE_FORMAT", "Only still JPEG, PNG, and WebP images are supported", { statusCode: 415, details: { detectedFormat: metadata.format || null, supportedFormats: [...SUPPORTED_FORMATS], }, }, ); } if ( !Number.isSafeInteger(metadata.width) || metadata.width < 1 || !Number.isSafeInteger(metadata.height) || metadata.height < 1 ) { throw new ImageProcessingError( "MALFORMED_IMAGE", "The uploaded image does not contain valid dimensions", { statusCode: 422 }, ); } } async function generateDerivative(input, target, maxInputPixels) { let output; try { output = await sharp(input, { failOn: "error", limitInputPixels: maxInputPixels, }) .autoOrient() .flatten({ background: BLACK }) .resize({ width: target.maxWidth, height: target.maxHeight, fit: "inside", withoutEnlargement: true, }) .toColourspace("srgb") .webp({ quality: target.quality, effort: 4 }) .toBuffer({ resolveWithObject: true }); } catch (error) { throw decodeError(error, maxInputPixels); } return { buffer: output.data, sha256: sha256(output.data), format: "webp", contentType: CONTENT_TYPES.webp, byteSize: output.data.length, width: output.info.width, height: output.info.height, }; } function originalMetadata(metadata, inputDescription) { const orientedDimensions = metadata.autoOrient || { width: metadata.width, height: metadata.height, }; return { sha256: inputDescription.sha256, format: metadata.format, contentType: CONTENT_TYPES[metadata.format], byteSize: inputDescription.byteSize, // Width and height describe how the original should appear after applying // its source orientation. The encoded dimensions remain available for // diagnostics without implying that the archived bytes were rewritten. width: orientedDimensions.width, height: orientedDimensions.height, encodedWidth: metadata.width, encodedHeight: metadata.height, orientation: metadata.orientation ?? null, pages: metadata.pages ?? 1, channels: metadata.channels, hasAlpha: Boolean(metadata.hasAlpha), }; } function createImageProcessor(options) { const config = normalizeOptions(options); async function process(input) { const inputDescription = await describeInput(input); const metadata = await readMetadata( inputDescription.sharpInput, config.maxInputPixels, ); validateMetadata(metadata); const [display, thumbnail] = await Promise.all([ generateDerivative( inputDescription.sharpInput, config.display, config.maxInputPixels, ), generateDerivative( inputDescription.sharpInput, config.thumbnail, config.maxInputPixels, ), ]); return { original: originalMetadata(metadata, inputDescription), display, thumbnail, }; } return Object.freeze({ process }); } module.exports = { DEFAULT_IMAGE_DERIVATIVE_OPTIONS, ImageProcessingError, createImageProcessor, };