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