/** * Pluggable storage backend (Phase 6a — see docs/berth-recommender-and-pdf-plan.md §4.7a). * * The CRM stores files (per-berth PDFs, brochures, GDPR exports, etc.) through a * single `StorageBackend` abstraction. The deployment chooses between an * S3-compatible store (MinIO / AWS S3 / Backblaze B2 / Cloudflare R2 / Wasabi / * Tigris) and a local filesystem at runtime via `system_settings.storage_backend`. * * Callers should always import from this barrel — never from `s3.ts` or * `filesystem.ts` directly — so the factory wiring stays the single source of * truth. */ import { and, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { logger } from '@/lib/logger'; import { FilesystemBackend } from './filesystem'; import { S3Backend } from './s3'; export type StorageBackendName = 's3' | 'filesystem'; export interface PutOpts { contentType: string; /** Optional pre-computed sha256 hex — if absent, the backend computes one. */ sha256?: string; /** Bytes (for streams that don't expose .length); used for capacity pre-flight. */ sizeBytes?: number; /** Optional content-disposition for downloads (filesystem proxy only). */ contentDisposition?: string; } export interface PresignOpts { /** TTL in seconds. Default 900 (15min) per SECURITY-GUIDELINES §7.1. */ expirySeconds?: number; contentType?: string; /** Filename used in Content-Disposition for downloads. */ filename?: string; } export interface StorageBackend { /** Upload bytes. Returns the canonical key, size, and sha256 hex. */ put( key: string, body: Buffer | NodeJS.ReadableStream, opts: PutOpts, ): Promise<{ key: string; sizeBytes: number; sha256: string }>; /** Stream a file out. Throws NotFoundError if missing. */ get(key: string): Promise; /** Existence + size check without reading the full body. Returns null when missing. */ head(key: string): Promise<{ sizeBytes: number; contentType: string } | null>; /** Delete. Idempotent — missing keys must not throw. */ delete(key: string): Promise; /** Generate a short-lived URL the browser can PUT to. */ presignUpload(key: string, opts: PresignOpts): Promise<{ url: string; method: 'PUT' | 'POST' }>; /** Generate a short-lived URL the browser can GET from. */ presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }>; readonly name: StorageBackendName; } // ─── factory ──────────────────────────────────────────────────────────────── interface CachedFactory { backend: StorageBackend; /** Resolved at cache-time so we can re-fetch when settings change. */ configFingerprint: string; } let cached: CachedFactory | null = null; /** * Reset the per-process backend cache. Called after `system_settings` writes * via the existing pub/sub invalidation hook, and exposed for tests. */ export function resetStorageBackendCache(): void { cached = null; } interface StorageConfigSnapshot { backend: StorageBackendName; s3?: { endpoint?: string; region?: string; bucket?: string; accessKey?: string; secretKeyEncrypted?: string; forcePathStyle?: boolean; }; filesystem?: { root?: string; proxyHmacSecretEncrypted?: string; }; } async function readGlobalSetting(key: string): Promise { const [row] = await db .select() .from(systemSettings) .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); return (row?.value as T | undefined) ?? null; } async function loadStorageConfig(): Promise { // Each setting key is a separate row. We read them in parallel. const keys = [ 'storage_backend', 'storage_s3_endpoint', 'storage_s3_region', 'storage_s3_bucket', 'storage_s3_access_key', 'storage_s3_secret_key_encrypted', 'storage_s3_force_path_style', 'storage_filesystem_root', 'storage_proxy_hmac_secret_encrypted', ] as const; const [ backendRaw, s3Endpoint, s3Region, s3Bucket, s3AccessKey, s3SecretKeyEncrypted, s3ForcePathStyle, fsRoot, fsHmacSecretEncrypted, ] = await Promise.all(keys.map((k) => readGlobalSetting(k))); const backend: StorageBackendName = backendRaw === 'filesystem' ? 'filesystem' : 's3'; return { backend, s3: { endpoint: typeof s3Endpoint === 'string' ? s3Endpoint : undefined, region: typeof s3Region === 'string' ? s3Region : undefined, bucket: typeof s3Bucket === 'string' ? s3Bucket : undefined, accessKey: typeof s3AccessKey === 'string' ? s3AccessKey : undefined, secretKeyEncrypted: typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined, forcePathStyle: typeof s3ForcePathStyle === 'boolean' ? s3ForcePathStyle : Boolean(s3ForcePathStyle), }, filesystem: { root: typeof fsRoot === 'string' ? fsRoot : undefined, proxyHmacSecretEncrypted: typeof fsHmacSecretEncrypted === 'string' ? fsHmacSecretEncrypted : undefined, }, }; } function fingerprint(cfg: StorageConfigSnapshot): string { return JSON.stringify(cfg); } /** * Resolve the active backend. Caches per-process; the cache is invalidated by * `resetStorageBackendCache()` (called when `system_settings.storage_backend` * changes via the migration flow). */ export async function getStorageBackend(): Promise { const cfg = await loadStorageConfig(); const fp = fingerprint(cfg); if (cached && cached.configFingerprint === fp) { return cached.backend; } const backend = await buildBackend(cfg); cached = { backend, configFingerprint: fp }; logger.info({ backend: backend.name }, 'Storage backend resolved'); return backend; } async function buildBackend(cfg: StorageConfigSnapshot): Promise { if (cfg.backend === 'filesystem') { return FilesystemBackend.create({ root: cfg.filesystem?.root ?? './storage', proxyHmacSecretEncrypted: cfg.filesystem?.proxyHmacSecretEncrypted ?? null, }); } return S3Backend.create({ endpoint: cfg.s3?.endpoint, region: cfg.s3?.region, bucket: cfg.s3?.bucket, accessKey: cfg.s3?.accessKey, secretKeyEncrypted: cfg.s3?.secretKeyEncrypted, forcePathStyle: cfg.s3?.forcePathStyle, }); } // ─── re-exports ───────────────────────────────────────────────────────────── export { S3Backend } from './s3'; export { FilesystemBackend, validateStorageKey } from './filesystem';