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:
@@ -75,6 +75,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cron-parser": "^5.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -142,6 +142,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
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)
|
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:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -4138,6 +4141,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
cron-parser@5.5.0:
|
||||||
|
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -10995,6 +11002,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
luxon: 3.7.2
|
luxon: 3.7.2
|
||||||
|
|
||||||
|
cron-parser@5.5.0:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ import { BerthPicker } from '@/components/shared/berth-picker';
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
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 {
|
interface UserOption {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -70,15 +82,25 @@ function ReminderFormBody({
|
|||||||
}: ReminderFormProps) {
|
}: ReminderFormProps) {
|
||||||
const isEdit = !!reminder;
|
const isEdit = !!reminder;
|
||||||
// Tomorrow 9am default for new-reminder dueAt.
|
// 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 defaultDueAt = useMemo(() => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
t.setDate(t.getDate() + 1);
|
t.setDate(t.getDate() + 1);
|
||||||
t.setHours(9, 0, 0, 0);
|
t.setHours(9, 0, 0, 0);
|
||||||
return t.toISOString().slice(0, 16);
|
return toLocalDatetimeLocal(t);
|
||||||
}, []);
|
}, []);
|
||||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||||
const [note, setNote] = useState(reminder?.note ?? '');
|
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 [priority, setPriority] = useState(reminder?.priority ?? 'medium');
|
||||||
const [assignedTo, setAssignedTo] = useState(reminder?.assignedTo ?? '');
|
const [assignedTo, setAssignedTo] = useState(reminder?.assignedTo ?? '');
|
||||||
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
|
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
|
||||||
|
|||||||
@@ -67,15 +67,36 @@ export async function registerRecurringJobs(): Promise<void> {
|
|||||||
{ queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' },
|
{ 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) {
|
for (const job of recurring) {
|
||||||
const queue = getQueue(job.queue);
|
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(
|
await queue.upsertJobScheduler(
|
||||||
job.name,
|
job.name,
|
||||||
{ pattern: job.pattern },
|
{ pattern: job.pattern, ...(tzInvariant ? {} : { tz: schedulerTz }) },
|
||||||
{ data: {}, name: job.name },
|
{ data: {}, name: job.name },
|
||||||
);
|
);
|
||||||
logger.info(
|
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',
|
'Registered recurring job',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ export const reportsWorker = new Worker(
|
|||||||
|
|
||||||
switch (job.name) {
|
switch (job.name) {
|
||||||
case 'report-scheduler': {
|
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 { db } = await import('@/lib/db');
|
||||||
const { scheduledReports } = await import('@/lib/db/schema/operations');
|
const { scheduledReports } = await import('@/lib/db/schema/operations');
|
||||||
const { generatedReports } = await import('@/lib/db/schema/operations');
|
const { generatedReports } = await import('@/lib/db/schema/operations');
|
||||||
const { eq, and, lte } = await import('drizzle-orm');
|
const { eq, and, lte } = await import('drizzle-orm');
|
||||||
|
const { CronExpressionParser } = await import('cron-parser');
|
||||||
|
|
||||||
const dueReports = await db
|
const dueReports = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -29,6 +38,35 @@ export const reportsWorker = new Worker(
|
|||||||
for (const report of dueReports) {
|
for (const report of dueReports) {
|
||||||
const { getQueue } = await import('@/lib/queue');
|
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
|
const [genReport] = await db
|
||||||
.insert(generatedReports)
|
.insert(generatedReports)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ export async function uploadFile(
|
|||||||
throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
|
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 entity = data.entityType ?? 'general';
|
||||||
const entityId = data.entityId ?? portId;
|
const entityId = data.entityId ?? portId;
|
||||||
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
|
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
|
||||||
@@ -66,9 +89,9 @@ export async function uploadFile(
|
|||||||
const sanitizedFilename = sanitizeFilename(data.filename);
|
const sanitizedFilename = sanitizeFilename(data.filename);
|
||||||
|
|
||||||
const backend = await getStorageBackend();
|
const backend = await getStorageBackend();
|
||||||
await backend.put(storagePath, file.buffer, {
|
await backend.put(storagePath, normalizedBuffer, {
|
||||||
contentType: file.mimeType,
|
contentType: file.mimeType,
|
||||||
sizeBytes: file.size,
|
sizeBytes: normalizedSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
// E8: auto-set entity FK from system-managed folder when the rep uploads
|
// E8: auto-set entity FK from system-managed folder when the rep uploads
|
||||||
@@ -82,7 +105,7 @@ export async function uploadFile(
|
|||||||
filename: sanitizedFilename,
|
filename: sanitizedFilename,
|
||||||
originalName: sanitizedOriginal,
|
originalName: sanitizedOriginal,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
sizeBytes: String(file.size),
|
sizeBytes: String(normalizedSize),
|
||||||
storagePath,
|
storagePath,
|
||||||
storageBucket: env.MINIO_BUCKET,
|
storageBucket: env.MINIO_BUCKET,
|
||||||
category: data.category ?? null,
|
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
|
* download endpoint, which presigns from the bucket using the id, not the
|
||||||
* raw path.
|
* raw path.
|
||||||
*/
|
*/
|
||||||
export type AggregatedFileRow = Omit<
|
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
|
||||||
typeof files.$inferSelect,
|
signedFromDocumentId: string | null;
|
||||||
'storagePath' | 'storageBucket'
|
};
|
||||||
> & { signedFromDocumentId: string | null };
|
|
||||||
|
|
||||||
export interface AggregatedFileGroup {
|
export interface AggregatedFileGroup {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
60
src/lib/services/image-normalize.ts
Normal file
60
src/lib/services/image-normalize.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
@@ -13,10 +13,19 @@ export function generateStorageKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeFilename(name: string): string {
|
export function sanitizeFilename(name: string): string {
|
||||||
return name
|
return (
|
||||||
.replace(/[/\\:]/g, '') // strip path chars
|
name
|
||||||
.replace(/\x00/g, '') // strip null bytes
|
.normalize('NFC')
|
||||||
.replace(/[\x01-\x1f\x7f]/g, '') // strip control chars
|
.replace(/[/\\:]/g, '') // strip path chars
|
||||||
.trim()
|
.replace(/\x00/g, '') // strip null bytes
|
||||||
.slice(0, 255);
|
.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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user