Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
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';
|
||
}
|