fix(storage): route every file op through getStorageBackend()

Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction.  Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc.  Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.

Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend.  A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).

Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 18:41:02 +02:00
parent 312779c0c5
commit cf430d70c3
12 changed files with 121 additions and 86 deletions

View File

@@ -18,10 +18,9 @@ import { db } from '@/lib/db';
import { gdprExports, type GdprExport } from '@/lib/db/schema/gdpr';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { env } from '@/lib/env';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { minioClient, getPresignedUrl } from '@/lib/minio';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { getQueue } from '@/lib/queue';
import { createAuditLog } from '@/lib/audit';
import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder';
@@ -163,9 +162,11 @@ export async function processGdprExportJob(input: ProcessJobInput): Promise<void
const portSlug = port?.slug ?? 'unknown';
const storageKey = `${portSlug}/gdpr-exports/${input.clientId}/${input.exportId}.zip`;
await minioClient.putObject(env.MINIO_BUCKET, storageKey, buffer, buffer.length, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="gdpr-export-${input.clientId}.zip"`,
const backend = await getStorageBackend();
await backend.put(storageKey, buffer, {
contentType: 'application/zip',
sizeBytes: buffer.length,
contentDisposition: `attachment; filename="gdpr-export-${input.clientId}.zip"`,
});
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
@@ -217,7 +218,7 @@ async function emailExport(input: ProcessJobInput, storageKey: string): Promise<
return;
}
const url = await getPresignedUrl(storageKey, PRESIGN_EXPIRY_SECONDS);
const url = await presignDownloadUrl(storageKey, PRESIGN_EXPIRY_SECONDS);
const client = await db.query.clients.findFirst({ where: eq(clients.id, input.clientId) });
const name = client?.fullName ?? 'there';
const expiry = new Date(Date.now() + PRESIGN_EXPIRY_SECONDS * 1000).toUTCString();
@@ -275,5 +276,5 @@ export async function getExportDownloadUrl(exportId: string, portId: string): Pr
if (!row.storageKey || (row.status !== 'ready' && row.status !== 'sent')) {
throw new ValidationError('Export is not ready to download');
}
return getPresignedUrl(row.storageKey, PRESIGN_EXPIRY_SECONDS);
return presignDownloadUrl(row.storageKey, PRESIGN_EXPIRY_SECONDS);
}