import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client'; import { preprocessImage } from '$lib/server/ai/image-preprocess'; import { pickRandomPhrase } from '$lib/server/ai/description-phrases'; import { createRateLimiter } from '$lib/server/ai/rate-limit'; import type { Ingredient, Step } from '$lib/types'; // 20 MB deckt auch Tablet- und iPad-Pro-Fotos ab (oft 10-15 MB JPEG/HEIC). // Muss zusammen mit BODY_SIZE_LIMIT (docker-compose.prod.yml) hochgezogen werden -- // SvelteKit rejected groessere Bodies frueher und wirft dann undurchsichtige // "Multipart erwartet"-Fehler. const MAX_BYTES = 20 * 1024 * 1024; const ALLOWED_MIME = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif' ]); // Singleton-Limiter: 10 Requests/Minute pro IP. Verhindert Kosten-Runaways // bei versehentlichem Dauer-Tappen. const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); function errJson(status: number, code: string, message: string) { return json({ code, message }, { status }); } function buildRawText(q: number | null, u: string | null, name: string): string { const parts: string[] = []; if (q !== null) parts.push(String(q).replace('.', ',')); if (u) parts.push(u); parts.push(name); return parts.join(' '); } export const POST: RequestHandler = async ({ request, getClientAddress }) => { const ip = getClientAddress(); if (!limiter.check(ip)) { return errJson( 429, 'RATE_LIMITED', 'Zu viele Anfragen — bitte einen Moment warten.' ); } let form: FormData; try { form = await request.formData(); } catch { return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); } const photo = form.get('photo'); if (!(photo instanceof Blob)) { return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); } if (photo.size > MAX_BYTES) { return errJson( 413, 'PAYLOAD_TOO_LARGE', `Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).` ); } if (!ALLOWED_MIME.has(photo.type)) { return errJson( 415, 'UNSUPPORTED_MEDIA_TYPE', `MIME "${photo.type}" nicht unterstützt.` ); } const rawBuffer = Buffer.from(await photo.arrayBuffer()); let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' }; try { preprocessed = await preprocessImage(rawBuffer); } catch (e) { return errJson( 415, 'UNSUPPORTED_MEDIA_TYPE', `Bild konnte nicht gelesen werden: ${(e as Error).message}` ); } const startedAt = Date.now(); let extracted; try { extracted = await extractRecipeFromImage( preprocessed.buffer, preprocessed.mimeType ); } catch (e) { if (e instanceof GeminiError) { const status = e.code === 'AI_RATE_LIMITED' ? 429 : e.code === 'AI_TIMEOUT' ? 503 : e.code === 'AI_NOT_CONFIGURED' ? 503 : 503; // Nur Code + Meta loggen, niemals Prompt/Response-Inhalt. console.warn( `[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes` ); return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); } console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`); return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.'); } // Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt). if ( !extracted.title.trim() || (extracted.ingredients.length === 0 && extracted.steps.length === 0) ) { return errJson( 422, 'NO_RECIPE_IN_IMAGE', 'Ich konnte kein Rezept im Bild erkennen.' ); } const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({ position: idx + 1, quantity: i.quantity, unit: i.unit, name: i.name, note: i.note, raw_text: buildRawText(i.quantity, i.unit, i.name), section_heading: null })); const steps: Step[] = extracted.steps.map((s, idx) => ({ position: idx + 1, text: s.text })); return json({ recipe: { id: null, title: extracted.title, description: pickRandomPhrase(), source_url: null, source_domain: null, image_path: null, servings_default: extracted.servings_default, servings_unit: extracted.servings_unit, prep_time_min: extracted.prep_time_min, cook_time_min: extracted.cook_time_min, total_time_min: extracted.total_time_min, cuisine: null, category: null, ingredients, steps, tags: [] } }); };