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>
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import { and, eq, gte, isNotNull, lte, sql } from 'drizzle-orm';
|
|
import { Hono } from 'hono';
|
|
|
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
|
import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/index.ts';
|
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
|
import { getStorage } from '../services/storage.ts';
|
|
import { buildUserExport } from './dsgvo.ts';
|
|
|
|
export type MeDeps = { db?: CardsDb };
|
|
|
|
/**
|
|
* User-Self-Service-Endpunkte. Auth: User-JWT/Dev-Stub. Identisch in
|
|
* Wirkung zum Service-Key-DSGVO-Pfad, aber gegen die eigene User-ID
|
|
* gated — der User braucht keinen mana-admin-Detour, um seine eigenen
|
|
* Daten zu sehen oder zu löschen.
|
|
*/
|
|
export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
|
const r = new Hono<{ Variables: AuthVars }>();
|
|
const dbOf = () => deps.db ?? getDb();
|
|
|
|
r.use('*', authMiddleware);
|
|
|
|
/** Voll-Export der eigenen Daten als JSON. */
|
|
r.get('/export', async (c) => {
|
|
const userId = c.get('userId');
|
|
return c.json(await buildUserExport(dbOf(), userId));
|
|
});
|
|
|
|
/**
|
|
* Statistik-Snapshot für die Account-/Stats-Page. Nicht-cached, alle
|
|
* Aggregate per Query. Ein einzelner Aufruf reicht für die /stats-UI.
|
|
*
|
|
* `reviewed_per_day` ist ein 7-Tage-Fenster (heute + 6 zurück), basierend
|
|
* auf `reviews.last_review` — nicht study_sessions, weil die aktuell
|
|
* nicht befüllt werden (Schema-Slot existiert, Writer kommt mit dem
|
|
* Session-Tracker in einer späteren Phase).
|
|
*/
|
|
r.get('/stats', async (c) => {
|
|
const userId = c.get('userId');
|
|
const db = dbOf();
|
|
const now = new Date();
|
|
const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const [deckCountRow] = await db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(decks)
|
|
.where(eq(decks.userId, userId));
|
|
|
|
const [cardCountRow] = await db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(cards)
|
|
.where(eq(cards.userId, userId));
|
|
|
|
const [reviewCountRow] = await db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(reviews)
|
|
.where(eq(reviews.userId, userId));
|
|
|
|
const [dueCountRow] = await db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(reviews)
|
|
.where(and(eq(reviews.userId, userId), lte(reviews.due, now)));
|
|
|
|
const stateRows = await db
|
|
.select({ state: reviews.state, n: sql<number>`count(*)::int` })
|
|
.from(reviews)
|
|
.where(eq(reviews.userId, userId))
|
|
.groupBy(reviews.state);
|
|
|
|
const stateCounts: Record<string, number> = {
|
|
new: 0,
|
|
learning: 0,
|
|
review: 0,
|
|
relearning: 0,
|
|
};
|
|
for (const row of stateRows) stateCounts[row.state] = row.n;
|
|
|
|
// Reviews pro Tag — gruppiert auf UTC-Tag.
|
|
const dayRows = await db
|
|
.select({
|
|
day: sql<string>`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`,
|
|
n: sql<number>`count(*)::int`,
|
|
})
|
|
.from(reviews)
|
|
.where(
|
|
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo))
|
|
)
|
|
.groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`);
|
|
|
|
const byDay = new Map(dayRows.map((r) => [r.day, r.n]));
|
|
|
|
const reviewed7 = Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(Date.now() - (6 - i) * 24 * 60 * 60 * 1000);
|
|
const key = d.toISOString().slice(0, 10);
|
|
return { day: key, n: byDay.get(key) ?? 0 };
|
|
});
|
|
|
|
// Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews.
|
|
let streak = 0;
|
|
for (let i = 0; i < 30; i++) {
|
|
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
|
const key = d.toISOString().slice(0, 10);
|
|
if ((byDay.get(key) ?? 0) > 0) streak++;
|
|
else break;
|
|
}
|
|
|
|
return c.json({
|
|
user_id: userId,
|
|
generated_at: now.toISOString(),
|
|
total_decks: deckCountRow?.n ?? 0,
|
|
total_cards: cardCountRow?.n ?? 0,
|
|
total_reviews: reviewCountRow?.n ?? 0,
|
|
due_now: dueCountRow?.n ?? 0,
|
|
state_counts: stateCounts,
|
|
reviewed_per_day: reviewed7,
|
|
streak_days: streak,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Vollständige Löschung der eigenen Cards-Daten. Identisch zur
|
|
* Service-Key-Variante in /dsgvo/delete, aber für den eingeloggten
|
|
* User selbst — ohne den Umweg über mana-admin.
|
|
*/
|
|
r.post('/delete', async (c) => {
|
|
const userId = c.get('userId');
|
|
const db = dbOf();
|
|
const [deletedDecks, deletedImports, deletedMediaFiles] = await db.transaction(
|
|
async (tx) => {
|
|
const dd = await tx
|
|
.delete(decks)
|
|
.where(eq(decks.userId, userId))
|
|
.returning({ id: decks.id });
|
|
const di = await tx
|
|
.delete(importJobs)
|
|
.where(eq(importJobs.userId, userId))
|
|
.returning({ id: importJobs.id });
|
|
const dm = await tx
|
|
.delete(mediaFiles)
|
|
.where(eq(mediaFiles.userId, userId))
|
|
.returning({ id: mediaFiles.id });
|
|
return [dd, di, dm];
|
|
}
|
|
);
|
|
|
|
let storageObjectsDeleted = 0;
|
|
try {
|
|
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
|
} catch (err) {
|
|
console.warn('[me/delete] storage sweep failed:', err);
|
|
}
|
|
|
|
return c.json({
|
|
deleted: true,
|
|
user_id: userId,
|
|
counts: {
|
|
decks: deletedDecks.length,
|
|
import_jobs: deletedImports.length,
|
|
media_files: deletedMediaFiles.length,
|
|
storage_objects: storageObjectsDeleted,
|
|
},
|
|
});
|
|
});
|
|
|
|
return r;
|
|
}
|