61 lines
2.4 KiB
TypeScript
61 lines
2.4 KiB
TypeScript
|
|
/**
|
|||
|
|
* Server-side image normalisation. Single funnel for every image
|
|||
|
|
* upload (avatar, generic file, scan, attachment) to:
|
|||
|
|
*
|
|||
|
|
* - **Strip EXIF** (incl. GPS coords, device serial, photographer name)
|
|||
|
|
* so uploaded photos don't leak per-pixel PII to anyone with a
|
|||
|
|
* download URL. asset-auditor C1.
|
|||
|
|
* - **Cap dimensions** at MAX_DIMENSION so a 30000×30000 palette PNG
|
|||
|
|
* can't decompression-bomb a sharp decode further downstream. C2.
|
|||
|
|
* - **Re-encode via sharp** so polyglot trailing bytes (PDF+JPEG
|
|||
|
|
* sandwiches, HTML+PNG) are dropped — the output buffer is a clean
|
|||
|
|
* single-format file regardless of input trickery. H1.
|
|||
|
|
* - **Freeze animated GIFs** to first frame so a 5000-frame phishing
|
|||
|
|
* GIF can't pin a worker on every list-view render. H3.
|
|||
|
|
*
|
|||
|
|
* Falls through to the original buffer (with a warning at the call
|
|||
|
|
* site) when sharp isn't available or the input isn't a recognised
|
|||
|
|
* image. The MIME type stays the same as declared — magic-byte
|
|||
|
|
* verification has already run upstream.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const MAX_DIMENSION = 4096;
|
|||
|
|
|
|||
|
|
export async function normalizeImage(
|
|||
|
|
input: Buffer,
|
|||
|
|
mimeType: string,
|
|||
|
|
): Promise<{ buffer: Buffer; format: string }> {
|
|||
|
|
const { default: sharp } = await import('sharp');
|
|||
|
|
|
|||
|
|
// Map MIME → sharp output format. Stay in-format so the stored
|
|||
|
|
// contentType keeps matching the bytes.
|
|||
|
|
const format = mimeFormat(mimeType);
|
|||
|
|
|
|||
|
|
let pipeline = sharp(input, { animated: false, pages: 1 })
|
|||
|
|
.rotate() // honour EXIF orientation BEFORE stripping it
|
|||
|
|
.resize({
|
|||
|
|
width: MAX_DIMENSION,
|
|||
|
|
height: MAX_DIMENSION,
|
|||
|
|
fit: 'inside',
|
|||
|
|
withoutEnlargement: true,
|
|||
|
|
})
|
|||
|
|
.withMetadata({ orientation: undefined });
|
|||
|
|
|
|||
|
|
// toFormat() drops anything non-format-shaped after re-encode.
|
|||
|
|
if (format === 'jpeg') pipeline = pipeline.jpeg({ quality: 88, mozjpeg: true });
|
|||
|
|
else if (format === 'png') pipeline = pipeline.png({ compressionLevel: 9 });
|
|||
|
|
else if (format === 'webp') pipeline = pipeline.webp({ quality: 88 });
|
|||
|
|
else if (format === 'gif') pipeline = pipeline.gif();
|
|||
|
|
|
|||
|
|
const buffer = await pipeline.toBuffer();
|
|||
|
|
return { buffer, format };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function mimeFormat(mimeType: string): 'jpeg' | 'png' | 'webp' | 'gif' | 'unknown' {
|
|||
|
|
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 'jpeg';
|
|||
|
|
if (mimeType === 'image/png') return 'png';
|
|||
|
|
if (mimeType === 'image/webp') return 'webp';
|
|||
|
|
if (mimeType === 'image/gif') return 'gif';
|
|||
|
|
return 'unknown';
|
|||
|
|
}
|