diff --git a/package.json b/package.json index c4083257..5b219631 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cron-parser": "^5.5.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7183ccb2..083d0f72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + cron-parser: + specifier: ^5.5.0 + version: 5.5.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -4138,6 +4141,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -10995,6 +11002,10 @@ snapshots: dependencies: luxon: 3.7.2 + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx index c7f173c0..c315de87 100644 --- a/src/components/reminders/reminder-form.tsx +++ b/src/components/reminders/reminder-form.tsx @@ -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 ``. 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. + // + // 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 ?? ''); diff --git a/src/lib/queue/scheduler.ts b/src/lib/queue/scheduler.ts index aa4d584f..540415eb 100644 --- a/src/lib/queue/scheduler.ts +++ b/src/lib/queue/scheduler.ts @@ -67,15 +67,36 @@ export async function registerRecurringJobs(): Promise { { queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' }, ]; + // BullMQ defaults `tz` to UTC. The cron patterns above are spelled + // in port-local time (e.g. "0 8 * * *" = 8 AM local), so without an + // explicit `tz` the jobs fire in UTC and silently drift across DST + // — twice a year the local firing time shifts by an hour and admin + // docs ("daily check at 8 AM") break. datetime-auditor C2. + // + // The CRM is single-port today (Port Nimara, Europe/Warsaw); when + // multi-port admin schedules ship, this should resolve per-job from + // the owning port's `ports.timezone`. SCHEDULER_TZ overrides for + // ops debugging without a redeploy. + const schedulerTz = process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'; + for (const job of recurring) { const queue = getQueue(job.queue); + // Sub-hourly / hourly patterns are TZ-invariant; skip the `tz` + // option for them so a misconfigured SCHEDULER_TZ can't perturb + // them. + const tzInvariant = /^\*|\*\/\d+ \*|^0 \*/.test(job.pattern); await queue.upsertJobScheduler( job.name, - { pattern: job.pattern }, + { pattern: job.pattern, ...(tzInvariant ? {} : { tz: schedulerTz }) }, { data: {}, name: job.name }, ); logger.info( - { queue: job.queue, job: job.name, pattern: job.pattern }, + { + queue: job.queue, + job: job.name, + pattern: job.pattern, + tz: tzInvariant ? 'UTC (invariant)' : schedulerTz, + }, 'Registered recurring job', ); } diff --git a/src/lib/queue/workers/reports.ts b/src/lib/queue/workers/reports.ts index 631743b5..7284a1d2 100644 --- a/src/lib/queue/workers/reports.ts +++ b/src/lib/queue/workers/reports.ts @@ -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({ diff --git a/src/lib/services/files.ts b/src/lib/services/files.ts index 6df27019..700eb397 100644 --- a/src/lib/services/files.ts +++ b/src/lib/services/files.ts @@ -59,6 +59,29 @@ export async function uploadFile( throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`); } + // Image normalisation (asset-auditor C1+C2+H1+H3): re-encode via + // sharp to strip EXIF (incl. GPS), drop polyglot trailing bytes, cap + // dimensions against decompression-bomb PNGs, and freeze animated + // GIFs to a single frame. Skips when sharp isn't available or the + // declared MIME isn't an image; failures fall back to the original + // buffer with a warning. + let normalizedBuffer = file.buffer; + let normalizedSize = file.size; + if (file.mimeType.startsWith('image/')) { + try { + const { normalizeImage } = await import('@/lib/services/image-normalize'); + const normalized = await normalizeImage(file.buffer, file.mimeType); + normalizedBuffer = normalized.buffer; + normalizedSize = normalized.buffer.byteLength; + } catch (err) { + const { logger } = await import('@/lib/logger'); + logger.warn( + { err, mimeType: file.mimeType }, + 'image normalization failed; storing original (EXIF retained)', + ); + } + } + const entity = data.entityType ?? 'general'; const entityId = data.entityId ?? portId; const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType); @@ -66,9 +89,9 @@ export async function uploadFile( const sanitizedFilename = sanitizeFilename(data.filename); const backend = await getStorageBackend(); - await backend.put(storagePath, file.buffer, { + await backend.put(storagePath, normalizedBuffer, { contentType: file.mimeType, - sizeBytes: file.size, + sizeBytes: normalizedSize, }); // E8: auto-set entity FK from system-managed folder when the rep uploads @@ -82,7 +105,7 @@ export async function uploadFile( filename: sanitizedFilename, originalName: sanitizedOriginal, mimeType: file.mimeType, - sizeBytes: String(file.size), + sizeBytes: String(normalizedSize), storagePath, storageBucket: env.MINIO_BUCKET, category: data.category ?? null, @@ -288,10 +311,9 @@ export async function getFileById(id: string, portId: string) { * download endpoint, which presigns from the bucket using the id, not the * raw path. */ -export type AggregatedFileRow = Omit< - typeof files.$inferSelect, - 'storagePath' | 'storageBucket' -> & { signedFromDocumentId: string | null }; +export type AggregatedFileRow = Omit & { + signedFromDocumentId: string | null; +}; export interface AggregatedFileGroup { label: string; diff --git a/src/lib/services/image-normalize.ts b/src/lib/services/image-normalize.ts new file mode 100644 index 00000000..5c546a65 --- /dev/null +++ b/src/lib/services/image-normalize.ts @@ -0,0 +1,60 @@ +/** + * 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'; +} diff --git a/src/lib/services/storage.ts b/src/lib/services/storage.ts index 18efa480..5e38391d 100644 --- a/src/lib/services/storage.ts +++ b/src/lib/services/storage.ts @@ -13,10 +13,19 @@ export function generateStorageKey( } export function sanitizeFilename(name: string): string { - return name - .replace(/[/\\:]/g, '') // strip path chars - .replace(/\x00/g, '') // strip null bytes - .replace(/[\x01-\x1f\x7f]/g, '') // strip control chars - .trim() - .slice(0, 255); + return ( + name + .normalize('NFC') + .replace(/[/\\:]/g, '') // strip path chars + .replace(/\x00/g, '') // strip null bytes + .replace(/[\x01-\x1f\x7f]/g, '') // strip control chars + // asset-auditor M2: strip Unicode bidi-control + zero-width + // characters that enable the classic Windows-icon spoof + // (`invoice_‮fdp.exe` renders as `invoice_exe.pdf`) and folder- + // listing collision spoofs (U+200B/U+FEFF). + + .replace(/[​-‏‪-‮⁦-⁩]/g, '') + .trim() + .slice(0, 255) + ); }