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
|
/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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user