Files
kochwas/src/routes/api/recipes/extract-from-photo/+server.ts
hsiegeln 3bc7fa16e2
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
feat(photo-upload): Limits hochschrauben fuer Tablet-Fotos
Tablet- und iPad-Pro-Kameras liefern JPEGs/HEICs bis 15 MB. Mit den
alten 8-/10-MB-Limits scheiterte das Upload beim SvelteKit-Body-Parser
mit "Multipart erwartet" (undurchsichtiger Fehler, weil SvelteKit den
Body frueher abweist als unser Endpoint-Check).

- Endpoint MAX_BYTES: 8 -> 20 MB
- BODY_SIZE_LIMIT: 10 -> 25 MB (mit Multipart-Overhead)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:31:34 +02:00

160 lines
4.6 KiB
TypeScript

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: []
}
});
};