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

@ -6,37 +6,123 @@
* (Anki-`::` zu ` / ` flacht die Hierarchie aus, wie im Original).
* Karten werden mit sanitisiertem Markdown angelegt.
*
* Phase-8-MVP: Bilder + Audio werden gedroppt (siehe parse.ts
* `sanitizeAnkiHtml`). Ein späterer Media-Pfad ist additiv.
* Phase 9k: Media-Upload via MinIO. Bilder + Audio werden vor den
* Karten in den Cards-Bucket geladen, der Sanitize-Pfad ersetzt
* Anki-Filenames durch echte Media-URLs (`/api/v1/media/<id>`).
*
* Phase-9j-Re-Import-Dedupe: Vor dem Insert wird der content_hash der
* Karte berechnet (gleiche Funktion wie der Server) und gegen die
* existierende Hash-Liste des Users geprüft. Duplikate werden gezählt
* und übersprungen Re-Imports bringen also keine doppelten Karten
* mehr ins Deck. Decks werden nicht dedupliziert (gewollt: zwei
* .apkg-Files mit identischen Decknamen sollen sich nicht
* versehentlich zusammenführen).
* Phase 9j Re-Import-Dedupe: content_hash-Set wird vor dem Loop
* geladen, Duplikate werden gezählt und übersprungen.
*/
import JSZip from 'jszip';
import { cardContentHash } from '@cards/domain';
import { createDeck } from '$lib/api/decks.ts';
import { createCard, listCardHashes } from '$lib/api/cards.ts';
import { uploadMedia } from '$lib/api/media.ts';
import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts';
export interface ImportResult {
decksCreated: number;
cardsCreated: number;
cardsSkippedDuplicate: number;
mediaUploaded: number;
mediaFailed: number;
failed: number;
failures: string[];
}
export interface ImportProgress {
stage: 'decks' | 'cards' | 'done';
stage: 'media' | 'decks' | 'cards' | 'done';
current: number;
total: number;
}
const MEDIA_CONCURRENCY = 4;
const IMG_RE = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi;
const SOUND_RE = /\[sound:([^\]]+)\]/g;
function collectMediaRefs(parsed: ParsedAnki): Set<string> {
const refs = new Set<string>();
for (const card of parsed.cards) {
for (const value of Object.values(card.fields)) {
let m: RegExpExecArray | null;
IMG_RE.lastIndex = 0;
while ((m = IMG_RE.exec(value))) refs.add(m[1]);
SOUND_RE.lastIndex = 0;
while ((m = SOUND_RE.exec(value))) refs.add(m[1]);
}
}
return refs;
}
function guessMime(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
oga: 'audio/ogg',
wav: 'audio/wav',
m4a: 'audio/mp4',
mp4: 'video/mp4',
webm: 'video/webm',
};
return map[ext] ?? 'application/octet-stream';
}
async function uploadAllMedia(
parsed: ParsedAnki,
onProgress?: (current: number, total: number) => void
): Promise<{ urlByFilename: Map<string, string>; uploaded: number; failed: number }> {
const referenced = [...collectMediaRefs(parsed)].filter((f) => parsed.mediaByFilename.has(f));
const urlByFilename = new Map<string, string>();
let uploaded = 0;
let failed = 0;
let done = 0;
if (referenced.length === 0) {
onProgress?.(0, 0);
return { urlByFilename, uploaded, failed };
}
let nextIdx = 0;
async function worker() {
while (true) {
const idx = nextIdx++;
if (idx >= referenced.length) return;
const filename = referenced[idx];
const entry = parsed.mediaByFilename.get(filename);
if (!entry) {
failed++;
done++;
onProgress?.(done, referenced.length);
continue;
}
try {
const blob = await (entry as JSZip.JSZipObject).async('blob');
const file = new File([blob], filename, { type: guessMime(filename) });
const result = await uploadMedia(file);
urlByFilename.set(filename, result.url);
uploaded++;
} catch (e) {
console.warn(`[anki-import] media upload failed for ${filename}:`, e);
failed++;
}
done++;
onProgress?.(done, referenced.length);
}
}
await Promise.all(Array.from({ length: MEDIA_CONCURRENCY }, () => worker()));
return { urlByFilename, uploaded, failed };
}
export async function importParsedAnki(
parsed: ParsedAnki,
opts: { onProgress?: (p: ImportProgress) => void } = {}
@ -45,22 +131,32 @@ export async function importParsedAnki(
decksCreated: 0,
cardsCreated: 0,
cardsSkippedDuplicate: 0,
mediaUploaded: 0,
mediaFailed: 0,
failed: 0,
failures: [],
};
// Vor dem Insert die Hash-Liste des Users laden — wenn der Endpoint
// fehlschlägt (z.B. älterer Server vor Phase 9j), fallen wir
// stillschweigend auf "kein Dedupe" zurück.
// Hash-Set vor dem Loop laden (Phase 9j-Dedupe).
const existingHashes = new Set<string>();
try {
const r = await listCardHashes();
for (const h of r.hashes) existingHashes.add(h);
} catch {
// Dedupe bleibt aus — Karten werden eingefügt wie zuvor.
// Dedupe bleibt aus (älterer Server o.ä.).
}
// 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
// 1) Media — vor den Karten uploaden, damit der Sanitize-Pfad echte
// URLs einsetzen kann. Files, die nicht im Anki-Manifest stehen,
// werden gedroppt; Upload-Fehler werden gezählt + im Card-Field
// gedroppt (statt 404-URL).
const { urlByFilename, uploaded, failed } = await uploadAllMedia(parsed, (current, total) => {
opts.onProgress?.({ stage: 'media', current, total });
});
result.mediaUploaded = uploaded;
result.mediaFailed = failed;
// 2) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
const ankiIdToDeckId = new Map<string, string>();
let deckIdx = 0;
for (const ankiDeck of parsed.decks) {
@ -76,7 +172,6 @@ export async function importParsedAnki(
}
}
// Fallback-Deck für Karten ohne explizit referenziertes Anki-Deck.
let fallbackDeckId: string | null = null;
const ensureFallbackDeck = async (): Promise<string | null> => {
if (fallbackDeckId) return fallbackDeckId;
@ -91,14 +186,14 @@ export async function importParsedAnki(
}
};
// 2) Cards — Felder sanitizen, content_hash prüfen, einfügen.
// 3) Cards — sanitize mit URL-Map, content_hash-Dedupe, Insert.
for (let i = 0; i < parsed.cards.length; i++) {
opts.onProgress?.({ stage: 'cards', current: i, total: parsed.cards.length });
const card = parsed.cards[i];
const cleanFields: Record<string, string> = {};
for (const [key, value] of Object.entries(card.fields)) {
cleanFields[key] = sanitizeAnkiHtml(value);
cleanFields[key] = sanitizeAnkiHtml(value, urlByFilename);
}
const hash = await cardContentHash({ type: card.type, fields: cleanFields });
@ -124,8 +219,6 @@ export async function importParsedAnki(
fields: cleanFields,
});
result.cardsCreated++;
// Hash sofort merken — derselbe Import könnte zwei identische
// Karten enthalten (Anki-Drift), zweite würde sonst auch rein.
existingHashes.add(hash);
} catch (e) {
result.failed++;

View file

@ -213,23 +213,41 @@ function mapNoteToCard(
/**
* Convert Anki's HTML / image / sound markup to plain text + Markdown.
*
* Phase-8-MVP: Bilder + Audio werden ersatzlos gedroppt (Option A).
* Ein späterer Media-Pfad (lokaler Cards-Upload-Endpunkt oder mana-media
* via Phase 2 Auth-Föderation) kann hier eine FilenameURL-Map einsetzen,
* die dann zu `<img>` / `<audio>`-Tags expandiert.
* `mediaUrlByFilename` mapt den Anki-Filename (wie er im Card-Field
* referenziert ist) auf eine echte App-URL. Was nicht in der Map ist,
* wird gedroppt das passiert z.B. wenn der Media-Upload für diese
* Datei fehlschlägt.
*
* `<img>` Markdown `![alt](url)`. `[sound:foo.mp3]` HTML
* `<audio src="url" controls>` (Markdown hat keine native Audio-Syntax,
* aber unser Renderer sanitized HTML mit DOMPurify und lässt `<audio>`
* durch).
*/
export function sanitizeAnkiHtml(html: string): string {
// Bilder + Audio-Refs vollständig entfernen.
const imgStripped = html.replace(/<img\b[^>]*>/gi, '');
const soundStripped = imgStripped.replace(/\[sound:[^\]]+\]/g, '');
export function sanitizeAnkiHtml(
html: string,
mediaUrlByFilename: Map<string, string> = new Map()
): string {
const imgReplaced = html.replace(
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
(_, src: string) => {
const url = mediaUrlByFilename.get(src);
return url ? `![${src}](${url})` : '';
}
);
const soundReplaced = imgReplaced.replace(/\[sound:([^\]]+)\]/g, (_, name: string) => {
const url = mediaUrlByFilename.get(name);
return url ? `<audio controls preload="metadata" src="${url}"></audio>` : '';
});
return soundStripped
return soundReplaced
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/?(?:b|strong)>/gi, '**')
.replace(/<\/?(?:i|em)>/gi, '*')
.replace(/<\/?p>/gi, '\n')
.replace(/<\/?div>/gi, '\n')
.replace(/<[^>]+>/gi, '')
// Drop remaining HTML tags except the ones we just emitted
// (audio/video/source) — die müssen den Renderer überleben.
.replace(/<(?!\/?(?:audio|video|source)\b)[^>]+>/gi, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')

View 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();
}

View file

@ -172,7 +172,9 @@
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
{#if progress.stage === 'decks'}
{#if progress.stage === 'media'}
{t('import.stage_media', { current: progress.current, total: progress.total })}
{:else if progress.stage === 'decks'}
{t('import.stage_decks', { current: progress.current, total: progress.total })}
{:else if progress.stage === 'cards'}
{t('import.stage_cards', { current: progress.current, total: progress.total })}
@ -204,6 +206,14 @@
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</div>
{/if}
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
<div class="text-[var(--color-muted)]">
{t('import.done_media', {
uploaded: result.mediaUploaded,
failed: result.mediaFailed,
})}
</div>
{/if}
{#if result.failed > 0}
<details class="text-[var(--color-danger)]">
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>

View file

@ -132,13 +132,14 @@ export const de: TranslationNode = {
what_works_decks: 'Decks (Anki-Hierarchie Foo::Bar wird zu Foo / Bar).',
what_works_basic: 'Basic + Basic-Reverse: Front/Back direkt.',
what_works_cloze: 'Cloze: {{c1::…}} wird mit Sub-Index pro Cluster angelegt.',
what_works_media: 'Bilder + Audio (eingebettet als Markdown bzw. <audio>-Tag).',
what_skipped_title: 'Was nicht übernommen wird',
what_skipped_media: 'Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).',
what_skipped_media: '— (Bilder + Audio werden seit Phase 9k mit übernommen, siehe oben)',
what_skipped_history: 'FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).',
what_skipped_addons: 'Add-on-spezifische Card-Types (image-occlusion etc.).',
anki_label: 'Aus Anki importieren',
dropzone: '📦 .apkg-Datei hier ablegen oder klicken',
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.',
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Bilder + Audio werden mit übernommen (Limit 25 MB pro Datei).',
parsing: 'Lese {file}…',
preview_found: 'Gefunden in',
preview_decks_one: '1 Deck',
@ -146,17 +147,19 @@ export const de: TranslationNode = {
preview_cards_one: '1 Karte',
preview_cards: '{n} Karten',
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
preview_media: '{n} Medien (werden in dieser Phase NICHT übernommen)',
preview_media: '{n} Medien werden mitgeladen',
preview_skipped: '{n} übersprungen (unbekannter Typ)',
preview_warnings: 'Hinweise ({n})',
cancel: 'Abbrechen',
import_now: 'Importieren',
stage_media: 'Lade Medien hoch · {current} / {total}',
stage_decks: 'Lege Decks an · {current} / {total}',
stage_cards: 'Importiere Karten · {current} / {total}',
stage_done: 'Fertig.',
done_summary_one: '✓ {cards} Karten in 1 Deck angelegt.',
done_summary: '✓ {cards} Karten in {decks} Decks angelegt.',
done_dupes: '{n} Duplikate übersprungen (gleicher Inhalt schon vorhanden).',
done_media: '{uploaded} Medien geladen, {failed} fehlgeschlagen.',
done_failures: '{n} Fehler',
done_more: 'Weitere Datei',
error_label: 'Fehler: {msg}',

View file

@ -129,13 +129,14 @@ export const en: TranslationNode = {
what_works_decks: 'Decks (Anki hierarchy Foo::Bar becomes Foo / Bar).',
what_works_basic: 'Basic + Basic-Reverse: front/back directly.',
what_works_cloze: 'Cloze: {{c1::…}} is created with sub-index per cluster.',
what_works_media: 'Images + audio (embedded as Markdown / <audio> tag).',
what_skipped_title: 'What is not imported',
what_skipped_media: 'Images + audio (will arrive with the platform integration in a later phase).',
what_skipped_media: '— (Images + audio are imported since Sprint 9k, see above)',
what_skipped_history: 'FSRS learning history (Anki reviews are deliberately reset).',
what_skipped_addons: 'Add-on specific card types (image-occlusion etc.).',
anki_label: 'Import from Anki',
dropzone: '📦 Drop .apkg file here or click',
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Images + audio are not imported in this phase.',
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Images + audio are imported too (limit 25 MB per file).',
parsing: 'Reading {file}…',
preview_found: 'Found in',
preview_decks_one: '1 deck',
@ -143,17 +144,19 @@ export const en: TranslationNode = {
preview_cards_one: '1 card',
preview_cards: '{n} cards',
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
preview_media: '{n} media files (will NOT be imported in this phase)',
preview_media: '{n} media files will be uploaded',
preview_skipped: '{n} skipped (unknown type)',
preview_warnings: 'Notes ({n})',
cancel: 'Cancel',
import_now: 'Import',
stage_media: 'Uploading media · {current} / {total}',
stage_decks: 'Creating decks · {current} / {total}',
stage_cards: 'Importing cards · {current} / {total}',
stage_done: 'Done.',
done_summary_one: '✓ {cards} cards in 1 deck.',
done_summary: '✓ {cards} cards in {decks} decks.',
done_dupes: '{n} duplicates skipped (same content already exists).',
done_media: '{uploaded} media uploaded, {failed} failed.',
done_failures: '{n} errors',
done_more: 'Another file',
error_label: 'Error: {msg}',

View file

@ -6,6 +6,13 @@ marked.setOptions({
breaks: true,
});
// DOMPurify-Default lässt <audio>/<video>/<source> NICHT durch. Wir
// erlauben sie explizit, weil der Anki-Importer Audio-Tags einbettet.
// `src` muss auf einer eigenen Allowlist sein, damit kein
// `javascript:`-URI durchschlüpft.
const ADD_TAGS = ['audio', 'video', 'source'];
const ADD_ATTR = ['controls', 'preload', 'src', 'type', 'autoplay', 'loop'];
/**
* Markdown HTML, sanitized via DOMPurify.
* Sicher gegen Stored-XSS aus User-Card-Inhalten.
@ -18,5 +25,5 @@ export function renderMarkdown(source: string): string {
if (!source) return '';
const html = marked.parse(source, { async: false }) as string;
if (typeof window === 'undefined') return html; // SSR-Fallback (selten Pfad)
return DOMPurify.sanitize(html);
return DOMPurify.sanitize(html, { ADD_TAGS, ADD_ATTR });
}

View file

@ -30,6 +30,7 @@
<li>{t('import.what_works_decks')}</li>
<li>{t('import.what_works_basic')}</li>
<li>{t('import.what_works_cloze')}</li>
<li>{t('import.what_works_media')}</li>
</ul>
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">{t('import.what_skipped_title')}</div>
<ul class="list-disc pl-4">