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:
@@ -7,6 +7,7 @@ node_modules
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
.env_*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.95.1",
|
||||
"version": "1.95.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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
|
||||
* Handles both simple fields and complex JSON-encoded nested structures
|
||||
|
||||
Reference in New Issue
Block a user