fix(recipes): build deploy against .env_prod; harden image save

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.
This commit is contained in:
2026-05-31 13:49:04 +02:00
parent 9fe9d95e36
commit 9b5cfe5e49
7 changed files with 61 additions and 19 deletions
+1
View File
@@ -7,6 +7,7 @@ node_modules
/package /package
.env .env
.env.* .env.*
.env_*
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.95.1", "version": "1.95.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+19 -1
View File
@@ -51,7 +51,25 @@ echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)" echo ":: Installing deps (frozen lockfile)"
pnpm install --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 pnpm build
if [[ ! -d build ]]; then if [[ ! -d build ]]; then
@@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing';
import { import {
extractRecipeFromFormData, extractRecipeFromFormData,
validateRecipeData, validateRecipeData,
serializeRecipeForDatabase serializeRecipeForDatabase,
serializableFormValues
} from '$utils/recipeFormHelpers'; } from '$utils/recipeFormHelpers';
export const load: PageServerLoad = async ({locals, params}) => { export const load: PageServerLoad = async ({locals, params}) => {
@@ -51,7 +52,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: validationErrors.join(', '), error: validationErrors.join(', '),
errors: validationErrors, errors: validationErrors,
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -87,7 +88,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `Failed to process image: ${message}`, error: `Failed to process image: ${message}`,
errors: ['Image processing failed'], errors: ['Image processing failed'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} else { } else {
@@ -127,7 +128,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'], errors: ['Duplicate short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -135,7 +136,7 @@ export const actions = {
return fail(500, { return fail(500, {
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`, error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage], errors: [dbMessage],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -13,7 +13,8 @@ import {
extractRecipeFromFormData, extractRecipeFromFormData,
validateRecipeData, validateRecipeData,
serializeRecipeForDatabase, serializeRecipeForDatabase,
detectChangedFields detectChangedFields,
serializableFormValues
} from '$utils/recipeFormHelpers'; } from '$utils/recipeFormHelpers';
/** /**
@@ -98,7 +99,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: 'Original short name is required for edit', error: 'Original short name is required for edit',
errors: ['Missing original_short_name'], errors: ['Missing original_short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -108,7 +109,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: validationErrors.join(', '), error: validationErrors.join(', '),
errors: validationErrors, errors: validationErrors,
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -143,7 +144,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `Failed to process image: ${message}`, error: `Failed to process image: ${message}`,
errors: ['Image processing failed'], errors: ['Image processing failed'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} else if (keepExistingImage && existingImagePath) { } else if (keepExistingImage && existingImagePath) {
@@ -206,7 +207,7 @@ export const actions = {
return fail(404, { return fail(404, {
error: `Recipe with short name "${originalShortName}" not found`, error: `Recipe with short name "${originalShortName}" not found`,
errors: ['Recipe not found'], errors: ['Recipe not found'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -263,7 +264,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'], errors: ['Duplicate short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -271,7 +272,7 @@ export const actions = {
return fail(500, { return fail(500, {
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`, error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage], errors: [dbMessage],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
+12 -5
View File
@@ -1,5 +1,5 @@
import path from 'path'; import path from 'path';
import { writeFile } from 'fs/promises'; import { writeFile, mkdir } from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash'; import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation'; 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 // the size the user saw in the editor — re-encoding through sharp would silently
// re-compress and discard their quality/size choice. // re-compress and discard their quality/size choice.
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before. // Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); const fullDir = path.join(imageDir, 'rezepte', 'full');
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); 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; let fullBuffer: Buffer;
if (file.type === 'image/webp') { if (file.type === 'image/webp') {
@@ -165,8 +172,8 @@ export async function processAndSaveRecipeImage(
.toBuffer(); .toBuffer();
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes'); console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename); const thumbHashedPath = path.join(thumbDir, hashedFilename);
const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename); const thumbUnhashedPath = path.join(thumbDir, unhashedFilename);
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath }); console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
await sharp(thumbBuffer).toFile(thumbHashedPath); await sharp(thumbBuffer).toFile(thumbHashedPath);
+14
View File
@@ -72,6 +72,20 @@ export interface RecipeFormData {
translationMetadata?: TranslationMetadata; 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<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (typeof value === 'string') out[key] = value;
}
return out;
}
/** /**
* Extracts recipe data from FormData * Extracts recipe data from FormData
* Handles both simple fields and complex JSON-encoded nested structures * Handles both simple fields and complex JSON-encoded nested structures