331 lines
8.5 KiB
JavaScript
331 lines
8.5 KiB
JavaScript
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,
|
|
};
|