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:
@@ -13,11 +13,20 @@ export const reportsWorker = new Worker(
|
||||
|
||||
switch (job.name) {
|
||||
case 'report-scheduler': {
|
||||
// Check scheduled_reports for reports due to run
|
||||
// Check scheduled_reports for reports due to run.
|
||||
//
|
||||
// datetime-auditor C3: the previous version selected due rows
|
||||
// and enqueued the generate-report job but NEVER advanced
|
||||
// `next_run_at`. The minutely scheduler then re-fired every
|
||||
// single tick until a human zeroed the row out — for
|
||||
// weekly/monthly reports that's an instant flood of dupe
|
||||
// emails to recipients. Now we compute the next fire from
|
||||
// the cron expression and UPDATE the row atomically.
|
||||
const { db } = await import('@/lib/db');
|
||||
const { scheduledReports } = await import('@/lib/db/schema/operations');
|
||||
const { generatedReports } = await import('@/lib/db/schema/operations');
|
||||
const { eq, and, lte } = await import('drizzle-orm');
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
|
||||
const dueReports = await db
|
||||
.select()
|
||||
@@ -29,6 +38,35 @@ export const reportsWorker = new Worker(
|
||||
for (const report of dueReports) {
|
||||
const { getQueue } = await import('@/lib/queue');
|
||||
|
||||
// Compute next_run_at BEFORE the enqueue so a failure in the
|
||||
// parse path (malformed cron) doesn't get repeat-fired.
|
||||
let nextRunAt: Date | null = null;
|
||||
try {
|
||||
nextRunAt = CronExpressionParser.parse(report.schedule, {
|
||||
currentDate: new Date(),
|
||||
tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw',
|
||||
})
|
||||
.next()
|
||||
.toDate();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, reportId: report.id, schedule: report.schedule },
|
||||
'Failed to parse cron schedule for scheduled report; pausing it',
|
||||
);
|
||||
// Disable the row so we don't re-attempt the malformed cron
|
||||
// every minute.
|
||||
await db
|
||||
.update(scheduledReports)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(scheduledReports.id, report.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(scheduledReports)
|
||||
.set({ nextRunAt, updatedAt: new Date() })
|
||||
.where(eq(scheduledReports.id, report.id));
|
||||
|
||||
const [genReport] = await db
|
||||
.insert(generatedReports)
|
||||
.values({
|
||||
|
||||
Reference in New Issue
Block a user