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)
+ );
}