/** * Object-Storage über MinIO (S3-API-kompatibel). * * Lokal: Container `cards-minio` (siehe infrastructure/docker-compose.yml) * auf 9100/9101 — Plattform-MinIO bleibt auf 9000/9001 ungestört. * * Produktiv (Phase 10): entweder eigener MinIO auf dem Mac Mini mit * separatem Bucket, oder gegen das Plattform-MinIO mit eigenem Bucket * `cards-media`. Konfiguration via env, kein Code-Pfad muss umgebogen * werden. */ import * as Minio from 'minio'; let cached: StorageService | null = null; export class StorageService { readonly client: Minio.Client; readonly bucket: string; private bucketReady = false; constructor() { this.client = new Minio.Client({ endPoint: process.env.CARDS_S3_ENDPOINT ?? 'localhost', port: Number(process.env.CARDS_S3_PORT ?? 9100), useSSL: process.env.CARDS_S3_USE_SSL === 'true', accessKey: process.env.CARDS_S3_ACCESS_KEY ?? 'cardsadmin', secretKey: process.env.CARDS_S3_SECRET_KEY ?? 'cardsadmin', }); this.bucket = process.env.CARDS_S3_BUCKET ?? 'cards-media'; } /** Idempotenter Bucket-Init. Wird einmal pro Process-Lifetime gerufen. */ async ensureBucket(): Promise { if (this.bucketReady) return; const exists = await this.client.bucketExists(this.bucket).catch(() => false); if (!exists) { await this.client.makeBucket(this.bucket); } this.bucketReady = true; } async putObject( key: string, body: Buffer | Uint8Array, contentType: string ): Promise { await this.ensureBucket(); await this.client.putObject(this.bucket, key, Buffer.from(body), body.byteLength, { 'Content-Type': contentType, }); } async getObjectStream(key: string): Promise { await this.ensureBucket(); return this.client.getObject(this.bucket, key); } async statObject(key: string): Promise<{ size: number; contentType: string }> { await this.ensureBucket(); const stat = await this.client.statObject(this.bucket, key); return { size: stat.size, contentType: stat.metaData?.['content-type'] ?? 'application/octet-stream', }; } async removeObject(key: string): Promise { await this.ensureBucket(); await this.client.removeObject(this.bucket, key); } async removeObjectsByPrefix(prefix: string): Promise { await this.ensureBucket(); const objectsStream = this.client.listObjectsV2(this.bucket, prefix, true); const keys: string[] = []; for await (const obj of objectsStream) { if (obj.name) keys.push(obj.name); } if (keys.length > 0) await this.client.removeObjects(this.bucket, keys); return keys.length; } } export function getStorage(): StorageService { if (!cached) cached = new StorageService(); return cached; } export function resetStorageForTests(): void { cached = null; }