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>
This commit is contained in:
@@ -20,6 +20,18 @@ import { BerthPicker } from '@/components/shared/berth-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
/**
|
||||
* Format a Date as `YYYY-MM-DDTHH:MM` in the user's local timezone,
|
||||
* suitable for `<input type="datetime-local">`. Round-trip-safe with
|
||||
* `new Date(value)` (which parses as local when no TZ marker is
|
||||
* present). Replaces `iso.toISOString().slice(0,16)` which strips the
|
||||
* `Z` and silently mis-interprets UTC as local.
|
||||
*/
|
||||
function toLocalDatetimeLocal(d: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -70,15 +82,25 @@ function ReminderFormBody({
|
||||
}: ReminderFormProps) {
|
||||
const isEdit = !!reminder;
|
||||
// Tomorrow 9am default for new-reminder dueAt.
|
||||
//
|
||||
// <input type="datetime-local"> takes/produces LOCAL wall-clock time
|
||||
// without a TZ marker. The naive round-trip (`iso.slice(0,16)` /
|
||||
// `toISOString().slice(0,16)`) subtracts the user's UTC offset on
|
||||
// every load+save cycle, drifting reminders backward by ~2h per save
|
||||
// in Warsaw (datetime-auditor C1). The helpers below build a local
|
||||
// YYYY-MM-DDTHH:MM string from the Date's getters and parse it back
|
||||
// with the user's offset preserved.
|
||||
const defaultDueAt = useMemo(() => {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(9, 0, 0, 0);
|
||||
return t.toISOString().slice(0, 16);
|
||||
return toLocalDatetimeLocal(t);
|
||||
}, []);
|
||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||
const [note, setNote] = useState(reminder?.note ?? '');
|
||||
const [dueAt, setDueAt] = useState(reminder ? reminder.dueAt.slice(0, 16) : defaultDueAt);
|
||||
const [dueAt, setDueAt] = useState(
|
||||
reminder ? toLocalDatetimeLocal(new Date(reminder.dueAt)) : defaultDueAt,
|
||||
);
|
||||
const [priority, setPriority] = useState(reminder?.priority ?? 'medium');
|
||||
const [assignedTo, setAssignedTo] = useState(reminder?.assignedTo ?? '');
|
||||
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
|
||||
|
||||
Reference in New Issue
Block a user