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:
Till JS 2026-05-08 18:42:56 +02:00
parent e7ae93dcf9
commit c9eb0a6f80
20 changed files with 886 additions and 78 deletions

View file

@ -22,8 +22,13 @@ export type {
export { tags } from './tags.ts';
export type { TagRow, TagInsert } from './tags.ts';
export { mediaRefs } from './media.ts';
export type { MediaRefRow, MediaRefInsert } from './media.ts';
export { mediaRefs, mediaFiles } from './media.ts';
export type {
MediaRefRow,
MediaRefInsert,
MediaFileRow,
MediaFileInsert,
} from './media.ts';
export { importJobs } from './imports.ts';
export type { ImportJobRow, ImportJobInsert } from './imports.ts';

View file

@ -4,9 +4,42 @@ import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts';
/**
* Media-Verweise auf Object-IDs in mana-media. Die eigentlichen Files
* (Bilder, Audio, Video) liegen in MinIO via mana-media; diese Tabelle
* hält nur den Verweis + Sortier-Order pro Karte.
* Media-Files: Bilder, Audio, Video, die in MinIO unter dem
* `objectKey` liegen und von Karten via cards.media_refs[]
* referenziert werden.
*
* Bewusst ohne FK auf eine konkrete Karte: ein File kann von
* mehreren Karten referenziert werden (z.B. ein Bild für Front
* und Back). Lifecycle-Cleanup per Cron oder DSGVO-Delete.
*
* objectKey-Format: `<userId>/<ulid>.<ext>` UserId-Präfix
* vereinfacht den DSGVO-Delete (Bucket-Prefix-Sweep).
*/
export const mediaFiles = cardsSchema.table(
'media_files',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
objectKey: text('object_key').notNull(),
mimeType: text('mime_type').notNull(),
originalFilename: text('original_filename'),
sizeBytes: integer('size_bytes').notNull(),
kind: text('kind', { enum: ['image', 'audio', 'video', 'other'] }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
userIdx: index('media_files_user_idx').on(t.userId),
})
);
export type MediaFileRow = typeof mediaFiles.$inferSelect;
export type MediaFileInsert = typeof mediaFiles.$inferInsert;
/**
* Legacy: media_refs aus Phase 1, Vor-Sprint-15. Bewusst behalten als
* Sortier-Layer-Slot für später (mana-media-Konvergenz). Aktuell leer.
*/
export const mediaRefs = cardsSchema.table(
'media_refs',