Files
pn-new-crm/src/lib/services/image-normalize.ts
Matt 2496911dc4 fix(audit-wave-11): asset hygiene + datetime correctness
**asset-auditor C1+C2+H1+H3 — image normalization**

Add `src/lib/services/image-normalize.ts` and wire it into
`uploadFile()` so every accepted image is re-encoded via sharp before
hitting storage:

- Strips EXIF (GPS coords, device serial, photographer) so uploaded
  photos don't leak per-pixel PII to anyone with a download URL (C1).
- Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})`
  so a 30000×30000 palette PNG can't decompression-bomb a downstream
  sharp decode (C2).
- Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat
  the prefix-only magic-byte check) (H1).
- Freezes animated GIFs to first frame (H3).

Avatar route already funnels through uploadFile so it's covered by
the single change.

**asset-auditor M2 — sanitizeFilename strips RTL/zero-width**

Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069)
+ zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`.
Closes the classic Windows-icon-spoof vector
(`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing
collision spoofs.

**datetime-auditor C1 — reminder dueAt drift on every save**

The `<input type="datetime-local">` round-trip in reminder-form.tsx
used `iso.slice(0,16)` (load) and `new Date(value).toISOString()`
(submit). The slice drops the `Z` so a UTC instant is mis-interpreted
as local on load, then converted back to UTC on save — every save
of an existing Warsaw reminder drifted backwards by 2h (CEST). After
two saves the reminder appears at 06:00 instead of 10:00.

Add `toLocalDatetimeLocal(d: Date)` helper that builds the local
YYYY-MM-DDTHH:MM string from getter methods so the round-trip is
TZ-safe. snooze-dialog already did this correctly; the contact-log
dialog also uses the correct localIsoString pattern.

**datetime-auditor C2 — BullMQ cron in UTC, not port-local**

`upsertJobScheduler` defaulted `tz` to UTC. Patterns like
`0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter
/ 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`.
Sub-hourly / hourly patterns are TZ-invariant and stay UTC.

**datetime-auditor C3 — report-scheduler never advanced next_run_at**

The minutely scheduler selected `nextRunAt <= now()` and enqueued
generate-report — but never bumped nextRunAt. For weekly/monthly
reports this meant the job re-fired every single minute until a
human zeroed the row out, flooding recipients with dupes.

Now uses `cron-parser` (added as a dep) to compute the next fire
from `report.schedule` and UPDATEs the row BEFORE the enqueue.
Malformed cron expressions disable the row instead of re-attempting
every minute.

Tests 1315/1315. Migration 0058 applied via psql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:58 +02:00

61 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
}