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:
213
src/utils/imageValidation.ts
Normal file
213
src/utils/imageValidation.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Image validation utility with comprehensive security checks
|
||||
*
|
||||
* Implements 5-layer security validation:
|
||||
* 1. File size check (5MB max)
|
||||
* 2. Magic bytes validation (detects actual file type)
|
||||
* 3. MIME type verification
|
||||
* 4. Extension validation
|
||||
* 5. Sharp structure validation
|
||||
*/
|
||||
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
// Valid magic bytes for image formats
|
||||
const MAGIC_BYTES = {
|
||||
jpeg: [0xFF, 0xD8, 0xFF],
|
||||
png: [0x89, 0x50, 0x4E, 0x47],
|
||||
webp: [0x52, 0x49, 0x46, 0x46] // RIFF header (WebP)
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates an uploaded image file with comprehensive security checks
|
||||
* @param file - The File object to validate
|
||||
* @returns ValidationResult with valid flag and optional error message
|
||||
*/
|
||||
export async function validateImageFile(file: File): Promise<ValidationResult> {
|
||||
// Layer 1: Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File size must be less than ${MAX_FILE_SIZE / 1024 / 1024}MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`
|
||||
};
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'File is empty'
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 2: Check MIME type (client-provided)
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid file type. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}. Received: ${file.type || 'unknown'}`
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 3: Check file extension
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid file extension. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}. Received: ${extension || 'none'}`
|
||||
};
|
||||
}
|
||||
|
||||
// Convert File to Buffer for magic bytes validation
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Layer 4: Magic bytes validation using file-type library
|
||||
try {
|
||||
const fileType = await fileTypeFromBuffer(buffer);
|
||||
|
||||
if (!fileType) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Unable to detect file type from file headers. File may be corrupted or not a valid image.'
|
||||
};
|
||||
}
|
||||
|
||||
// Verify detected type matches allowed types
|
||||
if (!ALLOWED_MIME_TYPES.includes(fileType.mime)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File headers indicate type "${fileType.mime}" which is not allowed. This file may have been renamed to bypass filters.`
|
||||
};
|
||||
}
|
||||
|
||||
// Verify MIME type consistency
|
||||
if (fileType.mime !== file.type) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File type mismatch: claimed to be "${file.type}" but actual type is "${fileType.mime}". Possible file spoofing attempt.`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to validate file headers: ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 5: Validate image structure with Sharp
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid image: unable to read image dimensions'
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata.width > 10000 || metadata.height > 10000) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum: 10000x10000px`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid or corrupted image file: ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Buffer containing image data (for base64-decoded images)
|
||||
* @param buffer - The Buffer to validate
|
||||
* @param filename - Original filename for extension validation
|
||||
* @returns ValidationResult with valid flag and optional error message
|
||||
*/
|
||||
export async function validateImageBuffer(buffer: Buffer, filename: string): Promise<ValidationResult> {
|
||||
// Layer 1: Check buffer size
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Buffer size must be less than ${MAX_FILE_SIZE / 1024 / 1024}MB. Current size: ${(buffer.length / 1024 / 1024).toFixed(2)}MB`
|
||||
};
|
||||
}
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Buffer is empty'
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 2: Check file extension
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid file extension. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}. Received: ${extension || 'none'}`
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 3: Magic bytes validation
|
||||
try {
|
||||
const fileType = await fileTypeFromBuffer(buffer);
|
||||
|
||||
if (!fileType) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Unable to detect file type from buffer headers. Buffer may be corrupted.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.includes(fileType.mime)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Buffer headers indicate type "${fileType.mime}" which is not allowed.`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to validate buffer headers: ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 4: Validate image structure with Sharp
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid image buffer: unable to read image dimensions'
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata.width > 10000 || metadata.height > 10000) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum: 10000x10000px`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid or corrupted image buffer: ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
371
src/utils/recipeFormHelpers.ts
Normal file
371
src/utils/recipeFormHelpers.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Recipe form serialization and validation helpers
|
||||
*
|
||||
* Utilities for converting between complex recipe data structures and FormData
|
||||
* for SvelteKit form actions with progressive enhancement support.
|
||||
*/
|
||||
|
||||
export interface RecipeFormData {
|
||||
// Basic fields
|
||||
name: string;
|
||||
short_name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
tags: string[];
|
||||
portions: string;
|
||||
season: number[];
|
||||
|
||||
// Optional text fields
|
||||
preamble?: string;
|
||||
addendum?: string;
|
||||
note?: string;
|
||||
|
||||
// Complex nested structures
|
||||
ingredients: any[];
|
||||
instructions: any[];
|
||||
|
||||
// Additional info
|
||||
add_info: {
|
||||
preparation?: string;
|
||||
fermentation?: {
|
||||
bulk?: string;
|
||||
final?: string;
|
||||
};
|
||||
baking?: {
|
||||
length?: string;
|
||||
temperature?: string;
|
||||
mode?: string;
|
||||
};
|
||||
total_time?: string;
|
||||
cooking?: string;
|
||||
};
|
||||
|
||||
// Images
|
||||
images?: Array<{
|
||||
mediapath: string;
|
||||
alt: string;
|
||||
caption: string;
|
||||
}>;
|
||||
|
||||
// Metadata
|
||||
isBaseRecipe?: boolean;
|
||||
datecreated?: Date;
|
||||
datemodified?: Date;
|
||||
|
||||
// Translation data (optional)
|
||||
translations?: {
|
||||
en?: any;
|
||||
};
|
||||
translationMetadata?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts recipe data from FormData
|
||||
* Handles both simple fields and complex JSON-encoded nested structures
|
||||
*/
|
||||
export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
|
||||
// Simple fields
|
||||
const name = formData.get('name')?.toString() || '';
|
||||
const short_name = formData.get('short_name')?.toString().trim() || '';
|
||||
const description = formData.get('description')?.toString() || '';
|
||||
const category = formData.get('category')?.toString() || '';
|
||||
const icon = formData.get('icon')?.toString() || '';
|
||||
const portions = formData.get('portions')?.toString() || '';
|
||||
|
||||
// Tags (comma-separated string or JSON array)
|
||||
let tags: string[] = [];
|
||||
const tagsData = formData.get('tags')?.toString();
|
||||
if (tagsData) {
|
||||
try {
|
||||
tags = JSON.parse(tagsData);
|
||||
} catch {
|
||||
// Fallback: split by comma
|
||||
tags = tagsData.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
}
|
||||
|
||||
// Season (JSON array of month numbers)
|
||||
let season: number[] = [];
|
||||
const seasonData = formData.get('season')?.toString();
|
||||
if (seasonData) {
|
||||
try {
|
||||
season = JSON.parse(seasonData);
|
||||
} catch {
|
||||
// Ignore invalid season data
|
||||
}
|
||||
}
|
||||
|
||||
// Optional text fields
|
||||
const preamble = formData.get('preamble')?.toString();
|
||||
const addendum = formData.get('addendum')?.toString();
|
||||
const note = formData.get('note')?.toString();
|
||||
|
||||
// Complex nested structures (JSON-encoded)
|
||||
let ingredients: any[] = [];
|
||||
const ingredientsData = formData.get('ingredients_json')?.toString();
|
||||
if (ingredientsData) {
|
||||
try {
|
||||
ingredients = JSON.parse(ingredientsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ingredients:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let instructions: any[] = [];
|
||||
const instructionsData = formData.get('instructions_json')?.toString();
|
||||
if (instructionsData) {
|
||||
try {
|
||||
instructions = JSON.parse(instructionsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse instructions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional info (JSON-encoded)
|
||||
let add_info: RecipeFormData['add_info'] = {};
|
||||
const addInfoData = formData.get('add_info_json')?.toString();
|
||||
if (addInfoData) {
|
||||
try {
|
||||
add_info = JSON.parse(addInfoData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse add_info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
let images: Array<{ mediapath: string; alt: string; caption: string }> = [];
|
||||
const imagesData = formData.get('images_json')?.toString();
|
||||
if (imagesData) {
|
||||
try {
|
||||
images = JSON.parse(imagesData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse images:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
const isBaseRecipe = formData.get('isBaseRecipe') === 'true';
|
||||
const datecreated = formData.get('datecreated')
|
||||
? new Date(formData.get('datecreated')!.toString())
|
||||
: new Date();
|
||||
const datemodified = new Date();
|
||||
|
||||
// Translation data (optional)
|
||||
let translations = undefined;
|
||||
const translationData = formData.get('translation_json')?.toString();
|
||||
if (translationData) {
|
||||
try {
|
||||
const translatedRecipe = JSON.parse(translationData);
|
||||
translations = { en: translatedRecipe };
|
||||
} catch (error) {
|
||||
console.error('Failed to parse translation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let translationMetadata = undefined;
|
||||
const translationMetadataData = formData.get('translation_metadata_json')?.toString();
|
||||
if (translationMetadataData) {
|
||||
try {
|
||||
translationMetadata = JSON.parse(translationMetadataData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse translation metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
short_name,
|
||||
description,
|
||||
category,
|
||||
icon,
|
||||
tags,
|
||||
portions,
|
||||
season,
|
||||
preamble,
|
||||
addendum,
|
||||
note,
|
||||
ingredients,
|
||||
instructions,
|
||||
add_info,
|
||||
images,
|
||||
isBaseRecipe,
|
||||
datecreated,
|
||||
datemodified,
|
||||
translations,
|
||||
translationMetadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates required recipe fields
|
||||
* Returns array of error messages (empty if valid)
|
||||
*/
|
||||
export function validateRecipeData(data: RecipeFormData): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data.name || data.name.trim() === '') {
|
||||
errors.push('Recipe name is required');
|
||||
}
|
||||
|
||||
if (!data.short_name || data.short_name.trim() === '') {
|
||||
errors.push('Short name (URL slug) is required');
|
||||
}
|
||||
|
||||
// Validate short_name format (URL-safe)
|
||||
if (data.short_name && !/^[a-z0-9_-]+$/i.test(data.short_name)) {
|
||||
errors.push('Short name must contain only letters, numbers, hyphens, and underscores');
|
||||
}
|
||||
|
||||
if (!data.description || data.description.trim() === '') {
|
||||
errors.push('Description is required');
|
||||
}
|
||||
|
||||
if (!data.category || data.category.trim() === '') {
|
||||
errors.push('Category is required');
|
||||
}
|
||||
|
||||
if (!data.ingredients || data.ingredients.length === 0) {
|
||||
errors.push('At least one ingredient is required');
|
||||
}
|
||||
|
||||
if (!data.instructions || data.instructions.length === 0) {
|
||||
errors.push('At least one instruction is required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects which fields have changed between two recipe objects
|
||||
* Used for edit forms to enable partial translation updates
|
||||
*/
|
||||
export function detectChangedFields(original: any, current: any): string[] {
|
||||
const changedFields: string[] = [];
|
||||
|
||||
// Simple field comparison
|
||||
const simpleFields = [
|
||||
'name',
|
||||
'short_name',
|
||||
'description',
|
||||
'category',
|
||||
'icon',
|
||||
'portions',
|
||||
'preamble',
|
||||
'addendum',
|
||||
'note'
|
||||
];
|
||||
|
||||
for (const field of simpleFields) {
|
||||
if (original[field] !== current[field]) {
|
||||
changedFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
// Array field comparison (deep compare)
|
||||
if (JSON.stringify(original.tags) !== JSON.stringify(current.tags)) {
|
||||
changedFields.push('tags');
|
||||
}
|
||||
|
||||
if (JSON.stringify(original.season) !== JSON.stringify(current.season)) {
|
||||
changedFields.push('season');
|
||||
}
|
||||
|
||||
if (JSON.stringify(original.ingredients) !== JSON.stringify(current.ingredients)) {
|
||||
changedFields.push('ingredients');
|
||||
}
|
||||
|
||||
if (JSON.stringify(original.instructions) !== JSON.stringify(current.instructions)) {
|
||||
changedFields.push('instructions');
|
||||
}
|
||||
|
||||
// Nested object comparison
|
||||
if (JSON.stringify(original.add_info) !== JSON.stringify(current.add_info)) {
|
||||
changedFields.push('add_info');
|
||||
}
|
||||
|
||||
if (JSON.stringify(original.images) !== JSON.stringify(current.images)) {
|
||||
changedFields.push('images');
|
||||
}
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses season data from form input
|
||||
* Handles both checkbox-based input and JSON arrays
|
||||
*/
|
||||
export function parseSeasonData(formData: FormData): number[] {
|
||||
const season: number[] = [];
|
||||
|
||||
// Try JSON format first
|
||||
const seasonJson = formData.get('season')?.toString();
|
||||
if (seasonJson) {
|
||||
try {
|
||||
return JSON.parse(seasonJson);
|
||||
} catch {
|
||||
// Fall through to checkbox parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Parse individual checkbox inputs (season_1, season_2, etc.)
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
if (formData.get(`season_${month}`) === 'true') {
|
||||
season.push(month);
|
||||
}
|
||||
}
|
||||
|
||||
return season;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes complex recipe data for storage
|
||||
* Ensures all required fields are present and properly typed
|
||||
*/
|
||||
export function serializeRecipeForDatabase(data: RecipeFormData): any {
|
||||
const recipe: any = {
|
||||
name: data.name,
|
||||
short_name: data.short_name,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
icon: data.icon || '',
|
||||
tags: data.tags || [],
|
||||
portions: data.portions || '',
|
||||
season: data.season || [],
|
||||
ingredients: data.ingredients || [],
|
||||
instructions: data.instructions || [],
|
||||
isBaseRecipe: data.isBaseRecipe || false,
|
||||
datecreated: data.datecreated || new Date(),
|
||||
datemodified: data.datemodified || new Date()
|
||||
};
|
||||
|
||||
// Optional fields
|
||||
if (data.preamble) recipe.preamble = data.preamble;
|
||||
if (data.addendum) recipe.addendum = data.addendum;
|
||||
if (data.note) recipe.note = data.note;
|
||||
|
||||
// Additional info
|
||||
if (data.add_info && Object.keys(data.add_info).length > 0) {
|
||||
recipe.preparation = data.add_info.preparation;
|
||||
recipe.fermentation = data.add_info.fermentation;
|
||||
recipe.baking = data.add_info.baking;
|
||||
recipe.total_time = data.add_info.total_time;
|
||||
recipe.cooking = data.add_info.cooking;
|
||||
}
|
||||
|
||||
// Images
|
||||
if (data.images && data.images.length > 0) {
|
||||
recipe.images = data.images;
|
||||
}
|
||||
|
||||
// Translations
|
||||
if (data.translations) {
|
||||
recipe.translations = data.translations;
|
||||
}
|
||||
|
||||
if (data.translationMetadata) {
|
||||
recipe.translationMetadata = data.translationMetadata;
|
||||
}
|
||||
|
||||
return recipe;
|
||||
}
|
||||
Reference in New Issue
Block a user