Files
Announcements/server/src/image-processing/index.js
T

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,
};