Compare commits
12 Commits
b88f1fbfa4
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb7c2f0e9b | ||
|
|
33ee6fbf2e | ||
|
|
e2713913e7 | ||
|
|
3bc7fa16e2 | ||
|
|
173d9d138d | ||
|
|
5492d4dc24 | ||
|
|
39de08abf9 | ||
|
|
fd7884e1b2 | ||
|
|
13728f9252 | ||
|
|
83f5b88d94 | ||
|
|
cb93725139 | ||
|
|
80c72b6e5b |
32
Dockerfile
32
Dockerfile
@@ -8,13 +8,39 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
|
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
# Sharp-Prebuilt-Install unter Docker-Buildx-QEMU war trotz aller Flag-
|
||||||
|
# Varianten unzuverlaessig. Finale Strategie:
|
||||||
|
# - --cpu/--os/--libc explizit setzen: sharp's offizielle Doc-Empfehlung
|
||||||
|
# fuer Cross-Platform-Docker-Builds (siehe sharp-Install-Doku),
|
||||||
|
# umgeht QEMU-Detection-Bugs.
|
||||||
|
# - --ignore-scripts + npm rebuild: loest das Parallel-Install-Race,
|
||||||
|
# bei dem sharp's install-Skript vor dem Entpacken der Prebuilt-Binary
|
||||||
|
# laeuft.
|
||||||
|
# - Explizites Nachinstallieren der Prebuilts als Sicherheit: falls (A)
|
||||||
|
# noch nicht reicht, zwingt (B) die Plattform-Pakete auf Disk.
|
||||||
|
# - node-addon-api + node-gyp als Runtime-Deps: falls am Ende doch alles
|
||||||
|
# nicht klappt und sharp from-source baut (mit dem oben installierten
|
||||||
|
# python3 + make + g++ + vips-dev).
|
||||||
|
RUN npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --include=optional --no-audit --no-fund
|
||||||
|
RUN npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --no-save --no-audit --no-fund \
|
||||||
|
@img/sharp-linuxmusl-arm64@0.34.5 \
|
||||||
|
@img/sharp-libvips-linuxmusl-arm64@1.2.4
|
||||||
|
RUN npm rebuild
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Remove dev dependencies for the runtime image
|
# Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
|
||||||
RUN npm prune --omit=dev
|
RUN rm -rf node_modules \
|
||||||
|
&& npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --omit=dev --include=optional --no-audit --no-fund \
|
||||||
|
&& npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --no-save --no-audit --no-fund \
|
||||||
|
@img/sharp-linuxmusl-arm64@0.34.5 \
|
||||||
|
@img/sharp-libvips-linuxmusl-arm64@1.2.4 \
|
||||||
|
&& npm rebuild
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ services:
|
|||||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
||||||
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
||||||
|
# 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:
|
depends_on:
|
||||||
- searxng
|
- searxng
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
822
package-lock.json
generated
822
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,8 @@
|
|||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"linkedom": "^0.18.5",
|
"linkedom": "^0.18.5",
|
||||||
"lucide-svelte": "^1.0.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
|
"node-addon-api": "^8.7.0",
|
||||||
|
"node-gyp": "^12.3.0",
|
||||||
"yauzl": "^3.3.0",
|
"yauzl": "^3.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import sharp from 'sharp';
|
import type SharpType from 'sharp';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const MAX_EDGE = 1600;
|
const MAX_EDGE = 1600;
|
||||||
const JPEG_QUALITY = 85;
|
const JPEG_QUALITY = 85;
|
||||||
@@ -8,10 +9,25 @@ export type PreprocessedImage = {
|
|||||||
mimeType: 'image/jpeg';
|
mimeType: 'image/jpeg';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// sharp per Node-Runtime-require laden, nicht via ES-Import: adapter-node
|
||||||
|
// bundelt ES-Imports (auch dynamische, auch mit @vite-ignore) ins Server-
|
||||||
|
// Bundle, was sharp's internes dynamic-require fuer die Plattform-.node-Binary
|
||||||
|
// zerstoert. createRequire + require() ist pure Node-Runtime-Logik, die
|
||||||
|
// Rollup nicht anfasst -- sharp wird regulaer aus node_modules geladen.
|
||||||
|
const nodeRequire = createRequire(import.meta.url);
|
||||||
|
let sharpModule: typeof SharpType | null = null;
|
||||||
|
function loadSharp(): typeof SharpType {
|
||||||
|
if (!sharpModule) {
|
||||||
|
sharpModule = nodeRequire('sharp') as typeof SharpType;
|
||||||
|
}
|
||||||
|
return sharpModule;
|
||||||
|
}
|
||||||
|
|
||||||
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
|
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
|
||||||
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
|
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
|
||||||
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
|
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
|
||||||
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
|
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
|
||||||
|
const sharp = loadSharp();
|
||||||
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
|
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
|
||||||
const meta = await pipeline.metadata();
|
const meta = await pipeline.metadata();
|
||||||
if (!meta.width || !meta.height) {
|
if (!meta.width || !meta.height) {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
|
|||||||
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
||||||
import type { Ingredient, Step } from '$lib/types';
|
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([
|
const ALLOWED_MIME = new Set([
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'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;
|
let form: FormData;
|
||||||
try {
|
try {
|
||||||
form = await request.formData();
|
form = await request.formData();
|
||||||
} catch {
|
} catch (e) {
|
||||||
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
|
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');
|
const photo = form.get('photo');
|
||||||
if (!(photo instanceof Blob)) {
|
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.');
|
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) {
|
if (photo.size > MAX_BYTES) {
|
||||||
return errJson(
|
return errJson(
|
||||||
413,
|
413,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
|
ImageUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
Wand2,
|
Wand2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
|
|
||||||
const store = new PhotoUploadStore();
|
const store = new PhotoUploadStore();
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let cameraInput = $state<HTMLInputElement | null>(null);
|
||||||
let fileInput = $state<HTMLInputElement | null>(null);
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
function onPick(e: Event) {
|
function onPick(e: Event) {
|
||||||
@@ -85,20 +87,42 @@
|
|||||||
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
|
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
|
||||||
scharf, gut ausgeleuchtet.
|
scharf, gut ausgeleuchtet.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div class="row">
|
||||||
type="button"
|
<button
|
||||||
class="btn primary"
|
type="button"
|
||||||
onclick={() => fileInput?.click()}
|
class="btn primary"
|
||||||
disabled={!network.online}
|
onclick={() => cameraInput?.click()}
|
||||||
>
|
disabled={!network.online}
|
||||||
<Camera size={18} strokeWidth={2} />
|
>
|
||||||
<span>Foto wählen oder aufnehmen</span>
|
<Camera size={18} strokeWidth={2} />
|
||||||
</button>
|
<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
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
hidden
|
hidden
|
||||||
onchange={onPick}
|
onchange={onPick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
|
|||||||
expect(body.recipe.id).toBeNull();
|
expect(body.recipe.id).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('413 when file exceeds 8 MB', async () => {
|
it('413 when file exceeds 20 MB', async () => {
|
||||||
const big = Buffer.alloc(9 * 1024 * 1024);
|
const big = Buffer.alloc(21 * 1024 * 1024);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
|
// sharp muss extern bleiben: der Server-Bundle-Schritt kann sharp's
|
||||||
|
// dynamic-require fuer die native .node-Binary nicht aufloesen. Wenn
|
||||||
|
// sharp nicht gebundelt wird, laedt Node es zur Laufzeit regulaer aus
|
||||||
|
// node_modules/@img/sharp-linuxmusl-arm64, das dann funktioniert.
|
||||||
|
ssr: {
|
||||||
|
external: ['sharp']
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
globals: false,
|
globals: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user