Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export const ALLOWED_MIME_TYPES = new Set<string>([
|
|
|
|
|
'image/jpeg',
|
|
|
|
|
'image/png',
|
|
|
|
|
'image/gif',
|
|
|
|
|
'image/webp',
|
|
|
|
|
'application/pdf',
|
|
|
|
|
'application/msword',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
|
|
'application/vnd.ms-excel',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
|
|
'text/plain',
|
|
|
|
|
'text/csv',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const MIME_TO_EXT: Record<string, string> = {
|
|
|
|
|
'image/jpeg': 'jpg',
|
|
|
|
|
'image/png': 'png',
|
|
|
|
|
'image/gif': 'gif',
|
|
|
|
|
'image/webp': 'webp',
|
|
|
|
|
'application/pdf': 'pdf',
|
|
|
|
|
'application/msword': 'doc',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
|
|
|
'application/vnd.ms-excel': 'xls',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
|
|
|
'text/plain': 'txt',
|
|
|
|
|
'text/csv': 'csv',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const MAX_FILE_SIZE = 52_428_800; // 50MB
|
|
|
|
|
|
|
|
|
|
export const PREVIEWABLE_MIMES = new Set<string>([
|
|
|
|
|
'image/jpeg',
|
|
|
|
|
'image/png',
|
|
|
|
|
'image/gif',
|
|
|
|
|
'image/webp',
|
|
|
|
|
'application/pdf',
|
|
|
|
|
]);
|
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Magic-byte signatures keyed by claimed MIME type. Used by the file
|
|
|
|
|
* upload handler to reject files whose first few bytes don't match the
|
|
|
|
|
* MIME the browser declared. Without this, a `<form>` could lie about
|
|
|
|
|
* Content-Type and pass arbitrary bytes through ALLOWED_MIME_TYPES.
|
|
|
|
|
*
|
|
|
|
|
* Each signature is the leading prefix of the file. When multiple variants
|
|
|
|
|
* exist (e.g. JPEG SOI + APPn marker), we accept any of them.
|
|
|
|
|
*/
|
|
|
|
|
export const MAGIC_BYTE_SIGNATURES: Record<string, Uint8Array[]> = {
|
|
|
|
|
'image/jpeg': [new Uint8Array([0xff, 0xd8, 0xff])],
|
|
|
|
|
'image/png': [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
|
|
|
'image/gif': [
|
|
|
|
|
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), // GIF87a
|
|
|
|
|
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF89a
|
|
|
|
|
],
|
|
|
|
|
'image/webp': [new Uint8Array([0x52, 0x49, 0x46, 0x46])], // RIFF; WEBP signature follows at offset 8
|
|
|
|
|
'application/pdf': [new Uint8Array([0x25, 0x50, 0x44, 0x46])], // %PDF
|
|
|
|
|
// Office formats are zip-based (modern: docx/xlsx) or OLE (legacy: doc/xls).
|
|
|
|
|
// Both share well-known magic bytes — match either family for a given MIME.
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
|
|
|
|
|
new Uint8Array([0x50, 0x4b, 0x03, 0x04]), // PK\3\4 (zip)
|
|
|
|
|
new Uint8Array([0x50, 0x4b, 0x05, 0x06]), // empty archive
|
|
|
|
|
],
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
|
|
|
|
|
new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
|
|
|
|
|
new Uint8Array([0x50, 0x4b, 0x05, 0x06]),
|
|
|
|
|
],
|
|
|
|
|
'application/msword': [
|
|
|
|
|
new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), // OLE compound
|
|
|
|
|
],
|
|
|
|
|
'application/vnd.ms-excel': [new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])],
|
|
|
|
|
// text/plain and text/csv have no magic bytes — leave unconstrained;
|
|
|
|
|
// size cap + ALLOWED_MIME_TYPES allow-list is the only gate.
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Returns true when the buffer starts with one of the registered prefixes
|
|
|
|
|
* for the given MIME, or when the MIME has no signature requirement. */
|
|
|
|
|
export function bufferMatchesMime(buffer: Buffer, mime: string): boolean {
|
|
|
|
|
const sigs = MAGIC_BYTE_SIGNATURES[mime];
|
|
|
|
|
if (!sigs) return true; // text/plain, text/csv, or unrecognised allow-list entry
|
|
|
|
|
return sigs.some((sig) => {
|
|
|
|
|
if (buffer.length < sig.length) return false;
|
|
|
|
|
for (let i = 0; i < sig.length; i++) {
|
|
|
|
|
if (buffer[i] !== sig[i]) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|