Phase 9k: Media-Upload via MinIO-Container
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>
This commit is contained in:
parent
e7ae93dcf9
commit
c9eb0a6f80
20 changed files with 886 additions and 78 deletions
42
apps/web/src/lib/api/media.ts
Normal file
42
apps/web/src/lib/api/media.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { API_BASE, ApiError } from './client.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
|
||||
export interface MediaUploadResult {
|
||||
id: string;
|
||||
url: string;
|
||||
mime_type: string;
|
||||
kind: 'image' | 'audio' | 'video' | 'other';
|
||||
size_bytes: number;
|
||||
original_filename: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein einzelnes File via multipart/form-data hoch. Anders als der
|
||||
* Standard-API-Helper geht das nicht über `Content-Type: application/json`,
|
||||
* deshalb hier ein eigener Pfad mit FormData + manuellem fetch.
|
||||
*/
|
||||
export async function uploadMedia(file: File | Blob, filename?: string): Promise<MediaUploadResult> {
|
||||
const form = new FormData();
|
||||
const wrapped = file instanceof File ? file : new File([file], filename ?? 'upload.bin');
|
||||
form.append('file', wrapped);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (devUser.id) headers['X-User-Id'] = devUser.id;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/media/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = await res.text().catch(() => null);
|
||||
}
|
||||
throw new ApiError(res.status, body, `media upload failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue