From 9b5cfe5e4978a7582c40f7f9a7fd98a14f82cc41 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 31 May 2026 13:49:04 +0200 Subject: [PATCH] fix(recipes): build deploy against .env_prod; harden image save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipe image upload failed in prod with ENOENT writing the full image. Root cause: the deploy built against the dev .env, whose relative IMAGE_DIR="./imgs/" resolves under the service's dist/ working dir instead of the real served image directory — and `$env/static/private` is inlined at build time, so dev values shipped to prod. - deploy.sh: source .env_prod (overridable via PROD_ENV) into the env before `pnpm build`, so prod values win over .env for the whole build lifecycle; abort if it's missing rather than ship a dev-env build. - .gitignore: ignore .env_* so .env_prod (prod secrets) isn't committed (the existing .env.* dot pattern didn't match the underscore form). - imageProcessing: mkdir -p the full/thumb dirs before writing. The WebP passthrough writes the full image with fs.writeFile, which (unlike sharp's toFile) does not create parent dirs. - recipeFormHelpers: add serializableFormValues() and use it in the add/ edit actions' fail() returns. Returning raw formData (now containing the recipe_image File) crashed the action response with a non-POJO devalue error, masking the real failure with an opaque 500. --- .gitignore | 1 + package.json | 2 +- scripts/deploy.sh | 20 ++++++++++++++++++- .../add/+page.server.ts | 11 +++++----- .../edit/[name]/+page.server.ts | 15 +++++++------- src/utils/imageProcessing.ts | 17 +++++++++++----- src/utils/recipeFormHelpers.ts | 14 +++++++++++++ 7 files changed, 61 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index be63b047..9f5256db 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules /package .env .env.* +.env_* !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/package.json b/package.json index d7f388cc..3d91ac58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.95.1", + "version": "1.95.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4cd9ed12..66622590 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -51,7 +51,25 @@ echo " node $local_node (match)" echo ":: Installing deps (frozen lockfile)" pnpm install --frozen-lockfile -echo ":: Building" +# Build against production env, NOT the dev .env. SvelteKit's +# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a +# build that picks up the dev .env ships dev values to prod — e.g. the relative +# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the +# real served image dir. We export .env_prod into the environment; real env vars +# take precedence over .env files in Vite/SvelteKit's env loading, so this wins +# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build). +PROD_ENV="${PROD_ENV:-.env_prod}" +if [[ ! -f "$PROD_ENV" ]]; then + echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env." + echo " Create $PROD_ENV with production values (IMAGE_DIR must be an" + echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)." + exit 1 +fi +echo ":: Building (env from $PROD_ENV)" +set -a +# shellcheck source=/dev/null +source "$PROD_ENV" +set +a pnpm build if [[ ! -d build ]]; then diff --git a/src/routes/[recipeLang=recipeLang]/add/+page.server.ts b/src/routes/[recipeLang=recipeLang]/add/+page.server.ts index 519d8186..8fef9a36 100644 --- a/src/routes/[recipeLang=recipeLang]/add/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/add/+page.server.ts @@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing'; import { extractRecipeFromFormData, validateRecipeData, - serializeRecipeForDatabase + serializeRecipeForDatabase, + serializableFormValues } from '$utils/recipeFormHelpers'; export const load: PageServerLoad = async ({locals, params}) => { @@ -51,7 +52,7 @@ export const actions = { return fail(400, { error: validationErrors.join(', '), errors: validationErrors, - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -87,7 +88,7 @@ export const actions = { return fail(400, { error: `Failed to process image: ${message}`, errors: ['Image processing failed'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } } else { @@ -127,7 +128,7 @@ export const actions = { return fail(400, { error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, errors: ['Duplicate short_name'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -135,7 +136,7 @@ export const actions = { return fail(500, { error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`, errors: [dbMessage], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } } catch (error: unknown) { diff --git a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.server.ts index 7b119b96..16d83b2b 100644 --- a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.server.ts @@ -13,7 +13,8 @@ import { extractRecipeFromFormData, validateRecipeData, serializeRecipeForDatabase, - detectChangedFields + detectChangedFields, + serializableFormValues } from '$utils/recipeFormHelpers'; /** @@ -98,7 +99,7 @@ export const actions = { return fail(400, { error: 'Original short name is required for edit', errors: ['Missing original_short_name'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -108,7 +109,7 @@ export const actions = { return fail(400, { error: validationErrors.join(', '), errors: validationErrors, - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -143,7 +144,7 @@ export const actions = { return fail(400, { error: `Failed to process image: ${message}`, errors: ['Image processing failed'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } } else if (keepExistingImage && existingImagePath) { @@ -206,7 +207,7 @@ export const actions = { return fail(404, { error: `Recipe with short name "${originalShortName}" not found`, errors: ['Recipe not found'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -263,7 +264,7 @@ export const actions = { return fail(400, { error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, errors: ['Duplicate short_name'], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } @@ -271,7 +272,7 @@ export const actions = { return fail(500, { error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`, errors: [dbMessage], - values: Object.fromEntries(formData) + values: serializableFormValues(formData) }); } } catch (error: unknown) { diff --git a/src/utils/imageProcessing.ts b/src/utils/imageProcessing.ts index 0fe70e52..fd1cdef2 100644 --- a/src/utils/imageProcessing.ts +++ b/src/utils/imageProcessing.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { writeFile } from 'fs/promises'; +import { writeFile, mkdir } from 'fs/promises'; import sharp from 'sharp'; import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash'; import { validateImageFile } from '$utils/imageValidation'; @@ -138,8 +138,15 @@ export async function processAndSaveRecipeImage( // the size the user saw in the editor — re-encoding through sharp would silently // re-compress and discard their quality/size choice. // Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before. - const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); - const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); + const fullDir = path.join(imageDir, 'rezepte', 'full'); + const thumbDir = path.join(imageDir, 'rezepte', 'thumb'); + // fs.writeFile (unlike sharp's toFile) does not create parent dirs, so ensure + // both target directories exist before writing. + await mkdir(fullDir, { recursive: true }); + await mkdir(thumbDir, { recursive: true }); + + const fullHashedPath = path.join(fullDir, hashedFilename); + const fullUnhashedPath = path.join(fullDir, unhashedFilename); let fullBuffer: Buffer; if (file.type === 'image/webp') { @@ -165,8 +172,8 @@ export async function processAndSaveRecipeImage( .toBuffer(); console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes'); - const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename); - const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename); + const thumbHashedPath = path.join(thumbDir, hashedFilename); + const thumbUnhashedPath = path.join(thumbDir, unhashedFilename); console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath }); await sharp(thumbBuffer).toFile(thumbHashedPath); diff --git a/src/utils/recipeFormHelpers.ts b/src/utils/recipeFormHelpers.ts index d8ab9010..b01b833c 100644 --- a/src/utils/recipeFormHelpers.ts +++ b/src/utils/recipeFormHelpers.ts @@ -72,6 +72,20 @@ export interface RecipeFormData { translationMetadata?: TranslationMetadata; } +/** + * Build a plain object of form values that is safe to return from a SvelteKit + * action (e.g. inside `fail(...)`). Drops File entries such as `recipe_image`, + * which devalue cannot serialize and which would otherwise crash the action + * response with a 500 ("Cannot stringify arbitrary non-POJOs"). + */ +export function serializableFormValues(formData: FormData): Record { + const out: Record = {}; + for (const [key, value] of formData.entries()) { + if (typeof value === 'string') out[key] = value; + } + return out; +} + /** * Extracts recipe data from FormData * Handles both simple fields and complex JSON-encoded nested structures