Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).
apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.
Neue Tabelle media_files in pgSchema('cards'):
id, user_id, object_key, mime_type, original_filename, size_bytes,
kind, created_at — kein FK auf cards (ein File kann mehreren Karten
gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.
Neuer Router /api/v1/media:
POST /upload — multipart, 25 MiB Default-Limit, image/audio/video
only (415 sonst), schreibt media_files-Row + speichert
in MinIO unter <userId>/<ulid>.<ext>
GET /:id — streamt aus MinIO mit Cache-Control: private,
immutable. Cross-User → 404 (nicht 403, anti-enumeration).
GET / — listet alle eigenen Files
DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.
Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.
apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.
i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.
Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415
53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92 lines
2.7 KiB
TypeScript
92 lines
2.7 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
await this.ensureBucket();
|
|
await this.client.putObject(this.bucket, key, Buffer.from(body), body.byteLength, {
|
|
'Content-Type': contentType,
|
|
});
|
|
}
|
|
|
|
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
|
|
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<void> {
|
|
await this.ensureBucket();
|
|
await this.client.removeObject(this.bucket, key);
|
|
}
|
|
|
|
async removeObjectsByPrefix(prefix: string): Promise<number> {
|
|
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;
|
|
}
|