feat(text-only): Wordeck-Cutoff für Image-Occlusion + Audio + MinIO
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
Ω-1: Text-Only-Architektur ist scharfgestellt. Code-Cleanup: - 4 Components gelöscht: ImageOcclusionEditor, ImageOcclusionView, AudioFrontView, AudioUploadField - 3 API-Module gelöscht: routes/media.ts, services/storage.ts, db/schema/media.ts (mediaRefs + mediaFiles), routes/decks-from-image.ts - packages/cards-domain: image-occlusion.ts + Tests entfernt, CardTypeSchema reduziert auf basic/basic-reverse/cloze/typing/multiple-choice - 3 Web-Routes (study/[deckId], cards/new, cards/[id]/edit) bereinigt: Image-Occlusion- und Audio-Front-Code-Pfade raus - anki/import.ts text-only: kein Media-Upload mehr, img/sound werden ersatzlos gestrippt - 21 weitere Files bereinigt: dto, health, me, dsgvo, tools, cards, decks, share-handlers, marketplace/decks, marketplace/fork, marketplace/pull-requests, AnkiImport.svelte DB-Migrationen (noch nicht gerannt, idempotent): - 0004_wordeck_text_only.sql: DELETE image-occlusion/audio (0 betroffene Rows), media_files-Tabelle DROP, media_refs-Spalte DROP, CHECK cards.type IN (basic, basic-reverse, cloze, type-in, multiple-choice) - 0005_wordeck_license_rename.sql: Cardecky-Personal-Use-1.0 → Wordeck-Personal-Use-1.0, Cardecky-Pro-Only-1.0 → Wordeck-Pro-Only-1.0, Default + CHECK + Backfill Infrastruktur: - docker-compose.production.yml: cards-minio-Service raus, MinIO-Envs aus cards-api raus, CARDS_PUBLIC_URL + PUBLIC_CARDS_API_URL auf wordeck.com / api.wordeck.com - App-Manifest schon vorher auf wordeck umgestellt Type-Check grün (api, domain, web — alle 3 Sub-Pakete). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1228eb4692
commit
e3c84a9249
42 changed files with 149 additions and 1955 deletions
36
apps/api/src/db/migrations/0005_wordeck_license_rename.sql
Normal file
36
apps/api/src/db/migrations/0005_wordeck_license_rename.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Wordeck-Rebrand: License-Strings Cardecky-* → Wordeck-*.
|
||||
--
|
||||
-- Bisher genutzte Lizenz-Identifier:
|
||||
-- Cardecky-Personal-Use-1.0 (Free-Default)
|
||||
-- Cardecky-Pro-Only-1.0 (Paid-Only)
|
||||
--
|
||||
-- Diese werden bei der Wordeck-Umbenennung in die Wordeck-Variante
|
||||
-- migriert (gleiche Bedingungen, neuer Brand).
|
||||
--
|
||||
-- 1. CHECK-Constraint anpassen
|
||||
-- 2. Bestehende Rows umbenennen
|
||||
-- 3. Default-Wert der Spalte anpassen
|
||||
--
|
||||
-- Idempotent (IF EXISTS / IF NOT EXISTS), damit Re-Runs harmlos sind.
|
||||
|
||||
-- 1. Alten CHECK-Constraint droppen (er erwartete 'Cardecky-Pro-Only-1.0')
|
||||
ALTER TABLE "marketplace"."decks"
|
||||
DROP CONSTRAINT IF EXISTS "decks_price_requires_license";
|
||||
|
||||
-- 2. Bestehende Rows umbenennen (Bestand-Migration)
|
||||
UPDATE "marketplace"."decks"
|
||||
SET "license" = 'Wordeck-Personal-Use-1.0'
|
||||
WHERE "license" = 'Cardecky-Personal-Use-1.0';
|
||||
|
||||
UPDATE "marketplace"."decks"
|
||||
SET "license" = 'Wordeck-Pro-Only-1.0'
|
||||
WHERE "license" = 'Cardecky-Pro-Only-1.0';
|
||||
|
||||
-- 3. Default der Spalte umstellen
|
||||
ALTER TABLE "marketplace"."decks"
|
||||
ALTER COLUMN "license" SET DEFAULT 'Wordeck-Personal-Use-1.0';
|
||||
|
||||
-- 4. Neuen CHECK mit Wordeck-Lizenzen
|
||||
ALTER TABLE "marketplace"."decks"
|
||||
ADD CONSTRAINT "decks_price_requires_license"
|
||||
CHECK (price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0');
|
||||
|
|
@ -9,10 +9,11 @@ import { decks } from './decks.ts';
|
|||
*
|
||||
* - basic / basic-reverse: { front, back }
|
||||
* - cloze: { text, extra? }
|
||||
* - type-in: { question, expected }
|
||||
* - image-occlusion: { image_ref, mask_regions: [...] }
|
||||
* - typing: { front, answer, aliases? }
|
||||
* - multiple-choice: { front, answer, distractor_pool?, explanation? }
|
||||
*
|
||||
* MVP unterstützt nur `basic` und `basic-reverse`.
|
||||
* Wordeck-Rebrand (2026-05-17): image-occlusion und audio-front sind
|
||||
* entfallen. Wordeck ist text-only.
|
||||
*
|
||||
* `tags` ist ein eigener Tag-Mapping-Layer (siehe `tags.ts` + `cardTags`),
|
||||
* NICHT in dieser Tabelle gespeichert.
|
||||
|
|
@ -27,7 +28,6 @@ export const cards = cardsSchema.table(
|
|||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(),
|
||||
fields: jsonb('fields').notNull(),
|
||||
mediaRefs: jsonb('media_refs').notNull().$type<string[]>().default([]),
|
||||
contentHash: text('content_hash'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
|
|
|
|||
|
|
@ -22,14 +22,6 @@ export type {
|
|||
export { tags } from './tags.ts';
|
||||
export type { TagRow, TagInsert } from './tags.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';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* Versions-Bumps hinweg erhalten kann.
|
||||
*
|
||||
* `price_credits = 0` heißt kostenlos. Alles > 0 erzwingt die
|
||||
* Cardecky-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene).
|
||||
* Wordeck-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
|
@ -39,8 +39,6 @@ export const cardTypeEnum = marketplaceSchema.enum('card_type', [
|
|||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
]);
|
||||
|
||||
|
|
@ -69,8 +67,8 @@ export const publicDecks = marketplaceSchema.table(
|
|||
],
|
||||
}),
|
||||
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
|
||||
// Cardecky-Personal-Use-1.0 (default für free), Cardecky-Pro-Only-1.0 (paid).
|
||||
license: text('license').notNull().default('Cardecky-Personal-Use-1.0'),
|
||||
// Wordeck-Personal-Use-1.0 (default für free), Wordeck-Pro-Only-1.0 (paid).
|
||||
license: text('license').notNull().default('Wordeck-Personal-Use-1.0'),
|
||||
priceCredits: integer('price_credits').notNull().default(0),
|
||||
ownerUserId: text('owner_user_id')
|
||||
.notNull()
|
||||
|
|
@ -90,7 +88,7 @@ export const publicDecks = marketplaceSchema.table(
|
|||
// Paid Decks müssen die Pro-Only-Lizenz tragen.
|
||||
priceLicense: check(
|
||||
'decks_price_requires_license',
|
||||
sql`price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'`
|
||||
sql`price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0'`
|
||||
),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import { index, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
import { cards } from './cards.ts';
|
||||
|
||||
/**
|
||||
* 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',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
cardId: text('card_id')
|
||||
.notNull()
|
||||
.references(() => cards.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
manaMediaObjectId: text('mana_media_object_id').notNull(),
|
||||
kind: text('kind', { enum: ['image', 'audio', 'video'] }).notNull(),
|
||||
ord: integer('ord').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
cardIdx: index('media_card_idx').on(t.cardId),
|
||||
})
|
||||
);
|
||||
|
||||
export type MediaRefRow = typeof mediaRefs.$inferSelect;
|
||||
export type MediaRefInsert = typeof mediaRefs.$inferInsert;
|
||||
|
|
@ -12,9 +12,7 @@ import { toolsRouter } from './routes/tools.ts';
|
|||
import { searchRouter } from './routes/search.ts';
|
||||
import { dsgvoRouter } from './routes/dsgvo.ts';
|
||||
import { meRouter } from './routes/me.ts';
|
||||
import { mediaRouter } from './routes/media.ts';
|
||||
import { decksGenerateRouter } from './routes/decks-generate.ts';
|
||||
import { decksFromImageRouter } from './routes/decks-from-image.ts';
|
||||
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
|
||||
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
|
||||
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
|
||||
|
|
@ -64,9 +62,7 @@ app.route('/api/v1/tools', toolsRouter());
|
|||
app.route('/api/v1/search', searchRouter());
|
||||
app.route('/api/v1/dsgvo', dsgvoRouter());
|
||||
app.route('/api/v1/me', meRouter());
|
||||
app.route('/api/v1/media', mediaRouter());
|
||||
app.route('/api/v1/decks/generate', decksGenerateRouter());
|
||||
app.route('/api/v1/decks/from-image', decksFromImageRouter());
|
||||
|
||||
// Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*.
|
||||
// Plan: docs/playbooks/MARKETPLACE_RESTORE.md.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export function toCardDto(row: typeof cards.$inferSelect) {
|
|||
user_id: row.userId,
|
||||
type: row.type,
|
||||
fields: row.fields,
|
||||
media_refs: row.mediaRefs ?? [],
|
||||
content_hash: row.contentHash,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
CardCreateSchema,
|
||||
CardUpdateSchema,
|
||||
cardContentHash,
|
||||
maskRegionCount,
|
||||
subIndexCount,
|
||||
subIndexCountForCloze,
|
||||
} from '@cards/domain';
|
||||
|
|
@ -43,9 +42,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
}
|
||||
const userId = c.get('userId');
|
||||
|
||||
// Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor
|
||||
// dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt
|
||||
// versehentlich auf 404 zu fallen.
|
||||
// Text-abhängige Sub-Index-Counts (Cloze) vor dem Deck-Lookup
|
||||
// auflösen — Validation-Errors bleiben 422 statt versehentlich
|
||||
// auf 404 zu fallen.
|
||||
let count: number;
|
||||
if (parsed.data.type === 'cloze') {
|
||||
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
||||
|
|
@ -55,17 +54,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
422
|
||||
);
|
||||
}
|
||||
} else if (parsed.data.type === 'image-occlusion') {
|
||||
count = maskRegionCount(parsed.data.fields.mask_regions ?? '');
|
||||
if (count === 0) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_input',
|
||||
issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'],
|
||||
},
|
||||
422
|
||||
);
|
||||
}
|
||||
} else {
|
||||
count = subIndexCount(parsed.data.type);
|
||||
}
|
||||
|
|
@ -95,7 +83,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
userId,
|
||||
type: parsed.data.type,
|
||||
fields: parsed.data.fields,
|
||||
mediaRefs: parsed.data.media_refs ?? [],
|
||||
contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -168,7 +155,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
.update(cards)
|
||||
.set({
|
||||
...(parsed.data.fields !== undefined && { fields: parsed.data.fields }),
|
||||
...(parsed.data.media_refs !== undefined && { mediaRefs: parsed.data.media_refs }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||
import { chatVisionJson } from '../services/llm-client.ts';
|
||||
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
||||
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
||||
|
||||
export type FromImageDeps = { db?: CardsDb };
|
||||
|
||||
const MAX_FILES = 5;
|
||||
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024;
|
||||
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024;
|
||||
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return mime.startsWith('image/') || mime === 'application/pdf';
|
||||
}
|
||||
|
||||
function maxBytesFor(mime: string): number {
|
||||
return mime === 'application/pdf' ? MAX_BYTES_PER_PDF : MAX_BYTES_PER_IMAGE;
|
||||
}
|
||||
|
||||
const InputSchema = z.object({
|
||||
language: z.enum(['de', 'en']).optional().default('de'),
|
||||
count: z.coerce.number().int().min(1).max(40).optional().default(15),
|
||||
url: z.string().url().max(2000).optional(),
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein Lerndesigner. Analysiere alle bereitgestellten Inhalte (Bilder, Dokumente, Texte, URLs) und erstelle daraus ein einziges zusammenhängendes Karteikarten-Deck für Spaced-Repetition-Lernen.
|
||||
|
||||
Du gibst NUR ein gültiges JSON-Objekt zurück, exakt mit diesem Schema:
|
||||
{
|
||||
"deck_name": "<kurzer Titel, max 80 Zeichen>",
|
||||
"deck_description": "<eine Zeile Beschreibung, optional>",
|
||||
"cards": [
|
||||
{ "front": "<Frage oder Begriff>", "back": "<Antwort oder Erklärung>" },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Regeln:
|
||||
- Front ist Frage / Begriff / Hinweis. Back ist Antwort / Definition / Erklärung.
|
||||
- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen.
|
||||
- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`).
|
||||
- KEIN HTML, KEIN Code-Fence außerhalb des JSON, KEINE Erklärung außerhalb des JSON.
|
||||
- Erstelle ein kohärentes Deck, das den Lernstoff aller Quellen zusammenfasst.`;
|
||||
|
||||
export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 10/min per User — Vision-LLM-Call ist besonders teuer.
|
||||
r.use('/', rateLimit({ scope: 'decks.from-image', windowMs: 60_000, max: 10, keyOf: userKey }));
|
||||
|
||||
r.post('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
|
||||
// Accept multipart (files + optional url) OR json (url-only, no files)
|
||||
const contentType = c.req.header('Content-Type') ?? '';
|
||||
let files: File[] = [];
|
||||
let rawInput: { language?: unknown; count?: unknown; url?: unknown } = {};
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const form = await c.req.formData().catch(() => null);
|
||||
if (!form) {
|
||||
return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400);
|
||||
}
|
||||
const rawFiles = form.getAll('file');
|
||||
files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type));
|
||||
rawInput = {
|
||||
language: form.get('language') ?? undefined,
|
||||
count: form.get('count') ?? undefined,
|
||||
url: form.get('url') ?? undefined,
|
||||
};
|
||||
} else {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return c.json({ error: 'invalid_input', detail: 'json or multipart body required' }, 400);
|
||||
}
|
||||
rawInput = body as { language?: unknown; count?: unknown; url?: unknown };
|
||||
}
|
||||
|
||||
const parsed = InputSchema.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { language, count, url } = parsed.data;
|
||||
|
||||
if (files.length === 0 && !url) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', detail: 'provide at least one image/PDF file or a URL' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (files.length > MAX_FILES) {
|
||||
return c.json({ error: 'invalid_input', detail: `max ${MAX_FILES} files per request` }, 400);
|
||||
}
|
||||
const oversized = files.find((f) => f.size > maxBytesFor(f.type));
|
||||
if (oversized) {
|
||||
const limit = oversized.type === 'application/pdf' ? '30 MiB' : '10 MiB';
|
||||
return c.json(
|
||||
{ error: 'invalid_input', detail: `"${oversized.name}" exceeds ${limit} limit` },
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const [images, urlContent] = await Promise.all([
|
||||
Promise.all(
|
||||
files.map(async (f) => ({
|
||||
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
|
||||
mimeType: f.type,
|
||||
})),
|
||||
),
|
||||
url ? fetchUrlContent(url) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// URL-only request where extraction failed → can't generate anything meaningful
|
||||
if (url && images.length === 0 && !urlContent) {
|
||||
return c.json(
|
||||
{ error: 'url_extraction_failed', detail: `could not extract content from ${url}` },
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceParts: string[] = [];
|
||||
if (images.length > 0) {
|
||||
const hasPdf = files.some((f) => f.type === 'application/pdf');
|
||||
sourceParts.push(
|
||||
hasPdf
|
||||
? images.length === 1 ? 'einem Dokument' : `${images.length} Dateien`
|
||||
: images.length === 1 ? 'einem Bild' : `${images.length} Bildern`,
|
||||
);
|
||||
}
|
||||
if (urlContent) sourceParts.push('URL-Inhalt');
|
||||
|
||||
const langLabel = language === 'de' ? 'Deutsch' : 'English';
|
||||
let userText = `Erstelle ${count} Lernkarten auf ${langLabel} aus ${sourceParts.join(' und ')}.`;
|
||||
|
||||
if (urlContent) {
|
||||
userText += `\n\nURL-Kontext (${url}):\n${urlContent}`;
|
||||
}
|
||||
|
||||
let generated: z.infer<typeof GeneratedDeckSchema>;
|
||||
try {
|
||||
const raw = await chatVisionJson<unknown>({
|
||||
images,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
userText,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const r2 = GeneratedDeckSchema.safeParse(raw);
|
||||
if (!r2.success) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'llm_returned_invalid_shape',
|
||||
issues: r2.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
||||
raw,
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
generated = r2.data;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
|
||||
}
|
||||
|
||||
const fallbackParts: string[] = [];
|
||||
if (images.length > 0) {
|
||||
const hasPdf = files.some((f) => f.type === 'application/pdf');
|
||||
fallbackParts.push(
|
||||
hasPdf
|
||||
? images.length === 1 ? 'Dokument' : `${images.length} Dateien`
|
||||
: images.length === 1 ? 'Bild' : `${images.length} Bildern`,
|
||||
);
|
||||
}
|
||||
if (url) fallbackParts.push('URL');
|
||||
const fallback = `KI-generiert aus ${fallbackParts.join(' + ')}`;
|
||||
|
||||
const result = await insertGeneratedDeck(dbOf(), userId, generated, fallback);
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
|
@ -70,7 +70,6 @@ export async function insertGeneratedDeck(
|
|||
userId,
|
||||
type: 'basic',
|
||||
fields: cr.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: cr.contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
userId,
|
||||
type: card.type,
|
||||
fields: card.fields as Record<string, string>,
|
||||
mediaRefs: card.mediaRefs,
|
||||
contentHash: card.contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,12 @@ import {
|
|||
cards,
|
||||
decks,
|
||||
importJobs,
|
||||
mediaFiles,
|
||||
mediaRefs,
|
||||
reviews,
|
||||
studySessions,
|
||||
tags,
|
||||
} from '../db/schema/index.ts';
|
||||
import { serviceKeyAuth } from '../middleware/service-key.ts';
|
||||
import { rateLimit, ipKey } from '../middleware/rate-limit.ts';
|
||||
import { getStorage } from '../services/storage.ts';
|
||||
import { auditLog } from '../lib/audit.ts';
|
||||
|
||||
export type DsgvoDeps = { db?: CardsDb };
|
||||
|
|
@ -31,8 +28,6 @@ export async function buildUserExport(db: CardsDb, userId: string) {
|
|||
reviewsRows,
|
||||
sessionsRows,
|
||||
tagsRows,
|
||||
mediaRefRows,
|
||||
mediaFileRows,
|
||||
importsRows,
|
||||
] = await Promise.all([
|
||||
db.select().from(decks).where(eq(decks.userId, userId)),
|
||||
|
|
@ -40,15 +35,13 @@ export async function buildUserExport(db: CardsDb, userId: string) {
|
|||
db.select().from(reviews).where(eq(reviews.userId, userId)),
|
||||
db.select().from(studySessions).where(eq(studySessions.userId, userId)),
|
||||
db.select().from(tags).where(eq(tags.userId, userId)),
|
||||
db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)),
|
||||
db.select().from(mediaFiles).where(eq(mediaFiles.userId, userId)),
|
||||
db.select().from(importJobs).where(eq(importJobs.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
exported_at: new Date().toISOString(),
|
||||
app: 'cards',
|
||||
app: 'wordeck',
|
||||
app_version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
data: {
|
||||
decks: decksRows.map((d) => ({
|
||||
|
|
@ -72,14 +65,6 @@ export async function buildUserExport(db: CardsDb, userId: string) {
|
|||
finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null,
|
||||
})),
|
||||
tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })),
|
||||
media_refs: mediaRefRows.map((m) => ({
|
||||
...m,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
})),
|
||||
media_files: mediaFileRows.map((f) => ({
|
||||
...f,
|
||||
createdAt: f.createdAt.toISOString(),
|
||||
})),
|
||||
import_jobs: importsRows.map((j) => ({
|
||||
...j,
|
||||
createdAt: j.createdAt.toISOString(),
|
||||
|
|
@ -130,16 +115,13 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
});
|
||||
|
||||
/**
|
||||
* Vollständige Löschung aller Cards-Daten eines Users. FK-Cascades
|
||||
* Vollständige Löschung aller Wordeck-Daten eines Users. FK-Cascades
|
||||
* räumen automatisch:
|
||||
* decks → cards → reviews
|
||||
* cards → media_refs
|
||||
* cards → card_tags
|
||||
* decks → cards → reviews / card_tags
|
||||
* decks → tags
|
||||
* decks → study_sessions
|
||||
* Verbleibend: import_jobs + media_files (eigene Tabellen ohne FK)
|
||||
* — werden separat gelöscht. MinIO-Objects werden per Bucket-Prefix-
|
||||
* Sweep entfernt (objectKey-Format `<userId>/<ulid>.<ext>`).
|
||||
* Verbleibend: import_jobs (eigene Tabelle ohne FK) — wird separat
|
||||
* gelöscht. Kein Object-Storage mehr (text-only ab Wordeck-Rebrand).
|
||||
*/
|
||||
r.post('/delete', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
|
|
@ -147,39 +129,17 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
|
||||
|
||||
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];
|
||||
}
|
||||
);
|
||||
|
||||
// MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep
|
||||
// scheitert, ist das nicht-fatal für die DB-Konsistenz (Files
|
||||
// ohne DB-Eintrag sind tote Bytes), aber der Caller MUSS es
|
||||
// erfahren — sonst meldet mana-admin dem User „alles gelöscht"
|
||||
// obwohl Media-Objekte überleben können.
|
||||
let storageObjectsDeleted = 0;
|
||||
let storageOk = true;
|
||||
let storageError: string | null = null;
|
||||
try {
|
||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||
} catch (err) {
|
||||
storageOk = false;
|
||||
storageError = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[dsgvo/delete] storage sweep failed:', err);
|
||||
}
|
||||
const [deletedDecks, deletedImports] = 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 });
|
||||
return [dd, di];
|
||||
});
|
||||
|
||||
auditLog({
|
||||
event: 'dsgvo.delete',
|
||||
|
|
@ -187,27 +147,19 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
auth_mode: 'service-key',
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: storageOk ? 'success' : 'partial',
|
||||
result: 'success',
|
||||
detail: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
storage_ok: storageOk,
|
||||
storage_error: storageError,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
deleted: true,
|
||||
user_id: userId,
|
||||
storage_ok: storageOk,
|
||||
...(storageError ? { storage_error: storageError } : {}),
|
||||
counts: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,38 +2,33 @@ import { Hono } from 'hono';
|
|||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { getDb } from '../db/connection.ts';
|
||||
import { getStorage } from '../services/storage.ts';
|
||||
|
||||
export const healthRoute = new Hono();
|
||||
|
||||
/**
|
||||
* Liveness — antwortet immer 200, solange der Prozess läuft.
|
||||
* Bewusst KEIN Downstream-Probe, damit ein kurzer DB-/MinIO-Hänger
|
||||
* Bewusst KEIN Downstream-Probe, damit ein kurzer DB-Hänger
|
||||
* nicht den Container in eine Restart-Schleife zwingt.
|
||||
*/
|
||||
healthRoute.get('/healthz', (c) => c.json({ status: 'ok' }));
|
||||
|
||||
/**
|
||||
* Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün,
|
||||
* Readiness mit Downstream-Probes. Status 200 wenn DB grün,
|
||||
* sonst 503 mit Aufschlüsselung welche Probe fehlgeschlagen ist.
|
||||
* Probes timen sich selbst aus über ein 1s-AbortSignal — eine träge
|
||||
* Abhängigkeit darf das Readiness-Signal nicht hängen lassen.
|
||||
*/
|
||||
healthRoute.get('/healthz/details', async (c) => {
|
||||
const [dbProbe, storageProbe] = await Promise.all([
|
||||
probeDb(),
|
||||
probeStorage(),
|
||||
]);
|
||||
const allOk = dbProbe.ok && storageProbe.ok;
|
||||
const dbProbe = await probeDb();
|
||||
const allOk = dbProbe.ok;
|
||||
return c.json(
|
||||
{
|
||||
status: allOk ? 'ok' : 'degraded',
|
||||
app: 'cards',
|
||||
app: 'wordeck',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
uptime_s: Math.floor(process.uptime()),
|
||||
checks: {
|
||||
db: dbProbe,
|
||||
storage: storageProbe,
|
||||
},
|
||||
},
|
||||
allOk ? 200 : 503
|
||||
|
|
@ -42,7 +37,7 @@ healthRoute.get('/healthz/details', async (c) => {
|
|||
|
||||
healthRoute.get('/version', (c) =>
|
||||
c.json({
|
||||
app: 'cards',
|
||||
app: 'wordeck',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
build: process.env.CARDS_BUILD_SHA ?? 'dev',
|
||||
})
|
||||
|
|
@ -61,16 +56,6 @@ async function probeDb(): Promise<ProbeResult> {
|
|||
}
|
||||
}
|
||||
|
||||
async function probeStorage(): Promise<ProbeResult> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await getStorage().ensureBucket();
|
||||
return { ok: true, latency_ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
|
|
|
|||
|
|
@ -74,8 +74,6 @@ const CardTypeSchema = z.enum([
|
|||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
]);
|
||||
|
||||
|
|
@ -178,13 +176,13 @@ export function marketplaceDecksRouter(
|
|||
return c.json({ error: 'invalid_slug', reason: validation.reason }, 422);
|
||||
}
|
||||
|
||||
const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0';
|
||||
const license = parsed.data.license ?? 'Wordeck-Personal-Use-1.0';
|
||||
const priceCredits = parsed.data.priceCredits ?? 0;
|
||||
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
|
||||
if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'paid_decks_require_pro_license',
|
||||
detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0',
|
||||
detail: 'priceCredits > 0 ⇒ license must be Wordeck-Pro-Only-1.0',
|
||||
},
|
||||
422
|
||||
);
|
||||
|
|
@ -248,7 +246,7 @@ export function marketplaceDecksRouter(
|
|||
|
||||
const license = parsed.data.license ?? deck.license;
|
||||
const priceCredits = parsed.data.priceCredits ?? deck.priceCredits;
|
||||
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
|
||||
if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') {
|
||||
return c.json({ error: 'paid_decks_require_pro_license' }, 422);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export async function forkDeckForUser(
|
|||
userId,
|
||||
type: sourceCard.type,
|
||||
fields: sourceCard.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: sourceCard.contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -342,7 +341,6 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au
|
|||
userId,
|
||||
type: card.type,
|
||||
fields: card.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: computedHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ const CardTypeEnum = z.enum([
|
|||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
]);
|
||||
|
||||
|
|
@ -335,8 +333,6 @@ export function pullRequestsRouter(
|
|||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice',
|
||||
fields: card.fields,
|
||||
ord: card.ord,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { and, desc, 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 { cards, decks, importJobs, 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';
|
||||
import { auditLog } from '../lib/audit.ts';
|
||||
|
||||
|
|
@ -19,15 +18,11 @@ function frontSnippetFor(type: string, fields: Record<string, string>): string {
|
|||
const raw =
|
||||
type === 'cloze'
|
||||
? (fields.text ?? '')
|
||||
: type === 'image-occlusion'
|
||||
? '[Bild-Karte]'
|
||||
: type === 'audio-front'
|
||||
? (fields.front ?? '[Audio-Karte]')
|
||||
: type === 'typing'
|
||||
? (fields.question ?? fields.front ?? '')
|
||||
: type === 'multiple-choice'
|
||||
? (fields.question ?? fields.front ?? '')
|
||||
: (fields.front ?? '');
|
||||
: type === 'typing'
|
||||
? (fields.question ?? fields.front ?? '')
|
||||
: type === 'multiple-choice'
|
||||
? (fields.question ?? fields.front ?? '')
|
||||
: (fields.front ?? '');
|
||||
const cleaned = raw
|
||||
.replace(/\{\{c\d+::([^:}]*)(?:::[^}]*)?\}\}/g, '$1')
|
||||
.replace(/[*_`#>]/g, '')
|
||||
|
|
@ -353,34 +348,17 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
|||
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;
|
||||
let storageOk = true;
|
||||
let storageError: string | null = null;
|
||||
try {
|
||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||
} catch (err) {
|
||||
storageOk = false;
|
||||
storageError = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[me/delete] storage sweep failed:', err);
|
||||
}
|
||||
const [deletedDecks, deletedImports] = 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 });
|
||||
return [dd, di];
|
||||
});
|
||||
|
||||
auditLog({
|
||||
event: 'dsgvo.delete',
|
||||
|
|
@ -389,27 +367,19 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
|||
auth_mode: c.get('authMode'),
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: storageOk ? 'success' : 'partial',
|
||||
result: 'success',
|
||||
detail: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
storage_ok: storageOk,
|
||||
storage_error: storageError,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
deleted: true,
|
||||
user_id: userId,
|
||||
storage_ok: storageOk,
|
||||
...(storageError ? { storage_error: storageError } : {}),
|
||||
counts: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||
import { ulid } from '../lib/ulid.ts';
|
||||
import { getStorage, type StorageService } from '../services/storage.ts';
|
||||
|
||||
export type MediaDeps = { db?: CardsDb; storage?: StorageService };
|
||||
|
||||
const MAX_BYTES = Number(process.env.CARDS_MEDIA_MAX_BYTES ?? 25 * 1024 * 1024); // 25 MiB
|
||||
const ALLOWED_PREFIXES = ['image/', 'audio/', 'video/'];
|
||||
|
||||
function kindFor(mime: string): MediaFileRow['kind'] {
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function extFor(mime: string, fallback?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/mp4': 'm4a',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
};
|
||||
if (map[mime]) return map[mime];
|
||||
if (fallback) {
|
||||
const dot = fallback.lastIndexOf('.');
|
||||
if (dot > 0 && dot < fallback.length - 1) return fallback.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
export function mediaRouter(deps: MediaDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
const storageOf = () => deps.storage ?? getStorage();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 30/min per User — Upload ist teuer (Storage + DB-Insert), aber
|
||||
// ein Anki-Import kann viele Files in Folge schicken; das Limit
|
||||
// soll Bursts ermöglichen, aber Dauer-Spam stoppen.
|
||||
r.use('/upload', rateLimit({ scope: 'media.upload', windowMs: 60_000, max: 30, keyOf: userKey }));
|
||||
|
||||
/**
|
||||
* Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles
|
||||
* andere wird mit 415 abgelehnt. Limit per env (Default 25 MiB).
|
||||
*
|
||||
* Antwort:
|
||||
* { id, url, mime_type, kind, size_bytes, original_filename }
|
||||
*
|
||||
* `url` ist ein relativer App-Pfad (`/api/v1/media/<id>`), den
|
||||
* Frontend + Anki-Importer in `<img src=...>` einsetzen können.
|
||||
* Public absolute URL kommt erst mit Phase 10 + DNS dazu.
|
||||
*/
|
||||
r.post('/upload', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const form = await c.req.formData().catch(() => null);
|
||||
if (!form) return c.json({ error: 'expected_multipart' }, 400);
|
||||
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) return c.json({ error: 'missing_file_field' }, 400);
|
||||
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED_PREFIXES.some((p) => mime.startsWith(p))) {
|
||||
return c.json({ error: 'unsupported_media_type', mime_type: mime }, 415);
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return c.json({ error: 'too_large', max_bytes: MAX_BYTES, got: file.size }, 413);
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
const ext = extFor(mime, file.name);
|
||||
const objectKey = `${userId}/${id}.${ext}`;
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
await storageOf().putObject(objectKey, buf, mime);
|
||||
|
||||
const [row] = await dbOf()
|
||||
.insert(mediaFiles)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
objectKey,
|
||||
mimeType: mime,
|
||||
originalFilename: file.name || null,
|
||||
sizeBytes: buf.byteLength,
|
||||
kind: kindFor(mime),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: row.id,
|
||||
url: `/api/v1/media/${row.id}`,
|
||||
mime_type: row.mimeType,
|
||||
kind: row.kind,
|
||||
size_bytes: row.sizeBytes,
|
||||
original_filename: row.originalFilename,
|
||||
},
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Streamt ein Media-File via MinIO-getObject. User-gated — fremde
|
||||
* Files sind 404 (nicht 403, damit IDs nicht enumerierbar sind).
|
||||
*/
|
||||
r.get('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const [row] = await dbOf()
|
||||
.select()
|
||||
.from(mediaFiles)
|
||||
.where(and(eq(mediaFiles.id, id), eq(mediaFiles.userId, userId)))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
const stream = await storageOf().getObjectStream(row.objectKey);
|
||||
c.header('Content-Type', row.mimeType);
|
||||
c.header('Content-Length', String(row.sizeBytes));
|
||||
c.header('Cache-Control', 'private, max-age=31536000, immutable');
|
||||
|
||||
return new Response(stream as unknown as ReadableStream, {
|
||||
status: 200,
|
||||
headers: c.res.headers,
|
||||
});
|
||||
});
|
||||
|
||||
/** Listet alle Media-Files des Users — nützlich fürs UI später. */
|
||||
r.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = await dbOf().select().from(mediaFiles).where(eq(mediaFiles.userId, userId));
|
||||
return c.json({
|
||||
files: rows.map((r) => ({
|
||||
id: r.id,
|
||||
url: `/api/v1/media/${r.id}`,
|
||||
mime_type: r.mimeType,
|
||||
kind: r.kind,
|
||||
size_bytes: r.sizeBytes,
|
||||
original_filename: r.originalFilename,
|
||||
created_at: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: rows.length,
|
||||
});
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
CardsCreateInputSchema,
|
||||
CardsSearchInputSchema,
|
||||
cardContentHash,
|
||||
maskRegionCount,
|
||||
subIndexCount,
|
||||
subIndexCountForCloze,
|
||||
} from '@cards/domain';
|
||||
|
|
@ -18,7 +17,7 @@ import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
|||
import { ulid } from '../lib/ulid.ts';
|
||||
import { searchUserCards } from '../lib/search.ts';
|
||||
|
||||
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
|
||||
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com';
|
||||
const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0';
|
||||
|
||||
export type ToolsDeps = { db?: CardsDb };
|
||||
|
|
@ -60,8 +59,7 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
|
||||
|
||||
// Text-abhängige Sub-Index-Counts identisch zum REST-Pfad
|
||||
// (cards.ts POST). Cloze ohne Cluster + Image-Occlusion
|
||||
// ohne Mask-Regions werden 422.
|
||||
// (cards.ts POST). Cloze ohne Cluster wird 422.
|
||||
let count: number;
|
||||
if (parsed.data.type === 'cloze') {
|
||||
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
||||
|
|
@ -71,17 +69,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
422
|
||||
);
|
||||
}
|
||||
} else if (parsed.data.type === 'image-occlusion') {
|
||||
count = maskRegionCount(parsed.data.fields.mask_regions ?? '');
|
||||
if (count === 0) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_input',
|
||||
issues: ['image-occlusion.mask_regions must be JSON array with >=1 region'],
|
||||
},
|
||||
422
|
||||
);
|
||||
}
|
||||
} else {
|
||||
count = subIndexCount(parsed.data.type);
|
||||
}
|
||||
|
|
@ -101,7 +88,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
userId,
|
||||
type: parsed.data.type,
|
||||
fields: parsed.data.fields,
|
||||
mediaRefs: parsed.data.media_refs ?? [],
|
||||
contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -118,7 +104,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
user_id: row.userId,
|
||||
type: row.type,
|
||||
fields: row.fields,
|
||||
media_refs: row.mediaRefs ?? [],
|
||||
content_hash: row.contentHash,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ export type HandlerResult = {
|
|||
resulting_id: string;
|
||||
};
|
||||
|
||||
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
|
||||
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com';
|
||||
|
||||
/**
|
||||
* Legt eine basic-Karte mit (front,back) im Inbox-Deck an, inkl.
|
||||
|
|
@ -38,7 +38,6 @@ async function persistCardInInbox(
|
|||
userId,
|
||||
type: 'basic',
|
||||
fields: { front, back },
|
||||
mediaRefs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue