refactor: migrate recipe forms to SvelteKit actions with secure image upload

Refactor recipe add/edit routes from client-side fetch to proper SvelteKit
form actions with progressive enhancement and comprehensive security improvements.

**Security Enhancements:**
- Implement 5-layer image validation (file size, MIME type, extension, magic bytes, Sharp structure)
- Replace insecure base64 JSON encoding with FormData for file uploads
- Add file-type@19 dependency for magic bytes validation
- Validate actual file type via magic bytes to prevent file type spoofing

**Progressive Enhancement:**
- Forms now work without JavaScript using native browser submission
- Add use:enhance for improved client-side UX when JS is available
- Serialize complex nested data (ingredients/instructions) via JSON in hidden fields
- Translation workflow integrated via programmatic form submission

**Bug Fixes:**
- Add type="button" to all interactive buttons in CreateIngredientList and CreateStepList
  to prevent premature form submission when clicking on ingredients/steps
- Fix SSR errors by using season_local state instead of get_season() DOM query
- Fix redirect handling in form actions (redirects were being caught as errors)
- Fix TranslationApproval to handle recipes without images using null-safe checks
- Add reactive effect to sync editableEnglish.images with germanData.images length
- Detect and hide 150x150 placeholder images in CardAdd component

**Features:**
- Make image uploads optional for recipe creation (use placeholder based on short_name)
- Handle three image scenarios in edit: keep existing, upload new, rename on short_name change
- Automatic image file renaming across full/thumb/placeholder directories when short_name changes
- Change detection for partial translation updates in edit mode

**Technical Changes:**
- Create imageValidation.ts utility with comprehensive file validation
- Create recipeFormHelpers.ts for data extraction, validation, and serialization
- Refactor /api/rezepte/img/add endpoint to use FormData instead of base64
- Update CardAdd component to upload via FormData immediately with proper error handling
- Use Image API for placeholder detection (avoids CORS issues with fetch)
This commit is contained in:
2026-01-13 14:21:15 +01:00
parent 3158ffc73a
commit df7c407941
12 changed files with 1777 additions and 866 deletions
+100 -52
View File
@@ -1,66 +1,114 @@
import path from 'path'
import path from 'path';
import type { RequestHandler } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { IMAGE_DIR } from '$env/static/private'
import { error, json } from '@sveltejs/kit';
import { IMAGE_DIR } from '$env/static/private';
import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation';
export const POST = (async ({ request, locals}) => {
const data = await request.json();
/**
* Secure image upload endpoint for recipe images
*
* SECURITY:
* - Requires authentication
* - 5-layer validation (size, magic bytes, MIME, extension, Sharp)
* - Uses FormData instead of base64 JSON (more efficient, more secure)
* - Generates full/thumb/placeholder versions
* - Content hash for cache busting
*
* @route POST /api/rezepte/img/add
*/
export const POST = (async ({ request, locals }) => {
// Check authentication
const auth = await locals.auth();
if (!auth) throw error(401, "Need to be logged in")
let full_res = new Buffer.from(data.image, 'base64')
if (!auth) {
throw error(401, 'Authentication required to upload images');
}
// Generate content hash for cache busting
const imageHash = generateImageHashFromBuffer(full_res);
const hashedFilename = getHashedFilename(data.name, imageHash);
const unhashedFilename = data.name + '.webp';
try {
const formData = await request.formData();
// reduce image size if over 500KB
const MAX_SIZE_KB = 500
//const metadata = await sharp(full_res).metadata()
////reduce image size if larger than 500KB
//if(metadata.size > MAX_SIZE_KB*1000){
// full_res = sharp(full_res).
// webp( { quality: 70})
// .toBuffer()
//}
// Extract image file and filename
const image = formData.get('image') as File;
const name = formData.get('name')?.toString().trim();
// Save full size - both hashed and unhashed versions
const fullBuffer = await sharp(full_res)
.toFormat('webp')
.toBuffer();
if (!image) {
throw error(400, 'No image file provided');
}
await sharp(fullBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "full", hashedFilename));
await sharp(fullBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "full", unhashedFilename));
if (!name) {
throw error(400, 'Image name is required');
}
// Save thumbnail - both hashed and unhashed versions
const thumbBuffer = await sharp(full_res)
.resize({ width: 800})
.toFormat('webp')
.toBuffer();
// Comprehensive security validation
const validationResult = await validateImageFile(image);
if (!validationResult.valid) {
throw error(400, validationResult.error || 'Invalid image file');
}
await sharp(thumbBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", hashedFilename));
await sharp(thumbBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", unhashedFilename));
// Convert File to Buffer for processing
const arrayBuffer = await image.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Save placeholder - both hashed and unhashed versions
const placeholderBuffer = await sharp(full_res)
.resize({ width: 20})
.toFormat('webp')
.toBuffer();
// Generate content hash for cache busting
const imageHash = generateImageHashFromBuffer(buffer);
const hashedFilename = getHashedFilename(name, imageHash);
const unhashedFilename = name + '.webp';
await sharp(placeholderBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", hashedFilename));
await sharp(placeholderBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", unhashedFilename))
return new Response(JSON.stringify({
msg: "Added image successfully",
filename: hashedFilename
}),{
status: 200,
});
// Process image with Sharp - convert to WebP format
// Save full size - both hashed and unhashed versions
const fullBuffer = await sharp(buffer)
.toFormat('webp')
.webp({ quality: 90 }) // High quality for full size
.toBuffer();
await sharp(fullBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'full', hashedFilename)
);
await sharp(fullBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'full', unhashedFilename)
);
// Save thumbnail (800px width) - both hashed and unhashed versions
const thumbBuffer = await sharp(buffer)
.resize({ width: 800 })
.toFormat('webp')
.webp({ quality: 85 })
.toBuffer();
await sharp(thumbBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'thumb', hashedFilename)
);
await sharp(thumbBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'thumb', unhashedFilename)
);
// Save placeholder (20px width) - both hashed and unhashed versions
const placeholderBuffer = await sharp(buffer)
.resize({ width: 20 })
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
await sharp(placeholderBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename)
);
await sharp(placeholderBuffer).toFile(
path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename)
);
return json({
success: true,
msg: 'Image uploaded successfully',
filename: hashedFilename,
unhashedFilename: unhashedFilename
});
} catch (err: any) {
// Re-throw errors that already have status codes
if (err.status) throw err;
// Log and throw generic error for unexpected failures
console.error('Image upload error:', err);
throw error(500, `Failed to upload image: ${err.message || 'Unknown error'}`);
}
}) satisfies RequestHandler;