Implement Phase 2 asset storage pipeline
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user