Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb7c2f0e9b | ||
|
|
33ee6fbf2e | ||
|
|
e2713913e7 | ||
|
|
3bc7fa16e2 |
@@ -17,9 +17,10 @@ services:
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
||||
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
||||
# adapter-node-Default ist 512 KB; Rezept-Fotos koennen bis 8 MB sein.
|
||||
# Multipart-Overhead einrechnen -> 10 MB gibt etwas Puffer.
|
||||
- BODY_SIZE_LIMIT=10000000
|
||||
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
|
||||
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
|
||||
# Multipart-Overhead.
|
||||
- BODY_SIZE_LIMIT=25000000
|
||||
depends_on:
|
||||
- searxng
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
|
||||
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
||||
import type { Ingredient, Step } from '$lib/types';
|
||||
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
// 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',
|
||||
@@ -41,16 +45,38 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Header-Snapshot fuer Diagnose beim Upload-Parse-Fehler. Wir loggen
|
||||
// Content-Type, -Length und User-Agent — nichts, was Inhalt verraet.
|
||||
const contentType = request.headers.get('content-type') ?? '(missing)';
|
||||
const contentLength = request.headers.get('content-length') ?? '(missing)';
|
||||
const userAgent = request.headers.get('user-agent')?.slice(0, 120) ?? '(missing)';
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
console.warn(
|
||||
`[extract-from-photo] formData() failed: name=${err.name} msg=${err.message} ` +
|
||||
`ct="${contentType}" len=${contentLength} ua="${userAgent}"`
|
||||
);
|
||||
return errJson(
|
||||
400,
|
||||
'BAD_REQUEST',
|
||||
`Upload konnte nicht gelesen werden (${err.name}: ${err.message}).`
|
||||
);
|
||||
}
|
||||
const photo = form.get('photo');
|
||||
if (!(photo instanceof Blob)) {
|
||||
console.warn(
|
||||
`[extract-from-photo] photo field missing or not a Blob. ct="${contentType}" ` +
|
||||
`len=${contentLength} fields=${[...form.keys()].join(',')}`
|
||||
);
|
||||
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
|
||||
}
|
||||
console.info(
|
||||
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
|
||||
);
|
||||
if (photo.size > MAX_BYTES) {
|
||||
return errJson(
|
||||
413,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
Camera,
|
||||
ImageUp,
|
||||
Loader2,
|
||||
Wand2,
|
||||
AlertTriangle,
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
const store = new PhotoUploadStore();
|
||||
let saving = $state(false);
|
||||
let cameraInput = $state<HTMLInputElement | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function onPick(e: Event) {
|
||||
@@ -85,20 +87,42 @@
|
||||
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
|
||||
scharf, gut ausgeleuchtet.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={!network.online}
|
||||
>
|
||||
<Camera size={18} strokeWidth={2} />
|
||||
<span>Foto wählen oder aufnehmen</span>
|
||||
</button>
|
||||
<div class="row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
onclick={() => cameraInput?.click()}
|
||||
disabled={!network.online}
|
||||
>
|
||||
<Camera size={18} strokeWidth={2} />
|
||||
<span>Kamera</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={!network.online}
|
||||
>
|
||||
<ImageUp size={18} strokeWidth={2} />
|
||||
<span>Aus Dateien</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
|
||||
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
|
||||
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
|
||||
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
|
||||
<input
|
||||
bind:this={cameraInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onchange={onPick}
|
||||
/>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onchange={onPick}
|
||||
/>
|
||||
|
||||
@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
|
||||
expect(body.recipe.id).toBeNull();
|
||||
});
|
||||
|
||||
it('413 when file exceeds 8 MB', async () => {
|
||||
const big = Buffer.alloc(9 * 1024 * 1024);
|
||||
it('413 when file exceeds 20 MB', async () => {
|
||||
const big = Buffer.alloc(21 * 1024 * 1024);
|
||||
const fd = new FormData();
|
||||
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
Reference in New Issue
Block a user