This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -193,6 +194,7 @@ async function startServer() {
|
||||
publicImageService,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({
|
||||
imageInspector: createSharpImageInspector(),
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
const options = { failOn: 'warning', limitInputPixels: 100_000_000 };
|
||||
const mimeTypes = { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
|
||||
|
||||
export function createSharpImageInspector() {
|
||||
return {
|
||||
async readMetadata(data) {
|
||||
const metadata = await sharp(data, options).metadata();
|
||||
return { width: metadata.width, height: metadata.height, mimeType: mimeTypes[metadata.format] };
|
||||
},
|
||||
async validateDecoding(data) {
|
||||
// Decode only after the service has checked metadata limits. The tiny
|
||||
// output is discarded; originals and derivatives retain their own flow.
|
||||
await sharp(data, options).resize(1, 1).raw().toBuffer();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
||||
import { createInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
|
||||
import { createInventoryImageSchema, uploadInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
const data = await part.toBuffer();
|
||||
const fields = part.fields;
|
||||
const originalFilename = part.filename;
|
||||
const result = createInventoryImageSchema.safeParse({
|
||||
const result = uploadInventoryImageSchema.safeParse({
|
||||
storageKey: `inventory/${request.params.id}/${randomUUID()}`,
|
||||
originalFilename,
|
||||
mimeType: part.mimetype,
|
||||
fileSize: data.length,
|
||||
width: Number(fields.width?.value),
|
||||
height: Number(fields.height?.value),
|
||||
altText: fields.altText?.value,
|
||||
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
|
||||
isPrimary: fields.isPrimary?.value === 'true',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { validateImageMove } from '../domain/image-order.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
|
||||
|
||||
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage }) {
|
||||
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector }) {
|
||||
return {
|
||||
async create(input) {
|
||||
validateInventoryImageMetadata(input);
|
||||
@@ -30,8 +30,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
|
||||
async upload(input) {
|
||||
const { data, ...metadata } = input;
|
||||
validateInventoryImageMetadata(metadata);
|
||||
validateImageSignature(data, metadata.mimeType);
|
||||
// Bound work before decoding; client dimensions and byte counts are not authoritative.
|
||||
validateInventoryImageMetadata({ ...metadata, fileSize: data.length, width: 1, height: 1 });
|
||||
let inspected;
|
||||
try { inspected = await imageInspector.readMetadata(data); }
|
||||
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
|
||||
if (inspected.mimeType !== metadata.mimeType) {
|
||||
throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image format does not match the declared MIME type.', 400);
|
||||
}
|
||||
Object.assign(metadata, { width: inspected.width, height: inspected.height, fileSize: data.length });
|
||||
validateInventoryImageMetadata(metadata);
|
||||
try { await imageInspector.validateDecoding(data); }
|
||||
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
|
||||
|
||||
await imageStorage.put({ key: metadata.storageKey, data });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user