2026-05-13 12:58:58 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
chore(autonomous-session): consolidate uncommitted work from prior session
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
2026-05-23 00:52:59 +02:00
|
|
|
|
* sandwiches, HTML+PNG) are dropped - the output buffer is a clean
|
2026-05-13 12:58:58 +02:00
|
|
|
|
* 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
|
chore(autonomous-session): consolidate uncommitted work from prior session
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
2026-05-23 00:52:59 +02:00
|
|
|
|
* image. The MIME type stays the same as declared - magic-byte
|
2026-05-13 12:58:58 +02:00
|
|
|
|
* 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';
|
|
|
|
|
|
}
|