diff --git a/src/routes/[recipeLang=recipeLang]/add/+page.server.ts b/src/routes/[recipeLang=recipeLang]/add/+page.server.ts index 5515bda..1fdcd51 100644 --- a/src/routes/[recipeLang=recipeLang]/add/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/add/+page.server.ts @@ -36,13 +36,19 @@ export const actions = { try { const formData = await request.formData(); + console.log('[RecipeAdd] Form data received'); // Extract recipe data from FormData const recipeData = extractRecipeFromFormData(formData); + console.log('[RecipeAdd] Recipe data extracted:', { + short_name: recipeData.short_name, + title: recipeData.title + }); // Validate required fields const validationErrors = validateRecipeData(recipeData); if (validationErrors.length > 0) { + console.error('[RecipeAdd] Validation errors:', validationErrors); return fail(400, { error: validationErrors.join(', '), errors: validationErrors, @@ -52,8 +58,16 @@ export const actions = { // Handle optional image upload const recipeImage = formData.get('recipe_image') as File | null; + console.log('[RecipeAdd] Recipe image from form:', { + hasImage: !!recipeImage, + size: recipeImage?.size, + name: recipeImage?.name, + type: recipeImage?.type + }); + if (recipeImage && recipeImage.size > 0) { try { + console.log('[RecipeAdd] Starting image processing...'); // Process and save the image const { filename } = await processAndSaveRecipeImage( recipeImage, @@ -61,13 +75,14 @@ export const actions = { IMAGE_DIR ); + console.log('[RecipeAdd] Image processed successfully, filename:', filename); recipeData.images = [{ mediapath: filename, alt: '', caption: '' }]; } catch (imageError: any) { - console.error('Image processing error:', imageError); + console.error('[RecipeAdd] Image processing error:', imageError); return fail(400, { error: `Failed to process image: ${imageError.message}`, errors: ['Image processing failed'], @@ -75,6 +90,7 @@ export const actions = { }); } } else { + console.log('[RecipeAdd] No image uploaded, using placeholder'); // No image uploaded - use placeholder based on short_name recipeData.images = [{ mediapath: `${recipeData.short_name}.webp`, diff --git a/src/routes/api/rezepte/img/add/+server.ts b/src/routes/api/rezepte/img/add/+server.ts index 7a71d21..64e5602 100644 --- a/src/routes/api/rezepte/img/add/+server.ts +++ b/src/routes/api/rezepte/img/add/+server.ts @@ -19,11 +19,15 @@ import { validateImageFile } from '$utils/imageValidation'; * @route POST /api/rezepte/img/add */ export const POST = (async ({ request, locals }) => { + console.log('[API:ImgAdd] Image upload request received'); + // Check authentication const auth = await locals.auth(); if (!auth) { + console.error('[API:ImgAdd] Authentication required'); throw error(401, 'Authentication required to upload images'); } + console.log('[API:ImgAdd] Authentication passed'); try { const formData = await request.formData(); @@ -32,71 +36,99 @@ export const POST = (async ({ request, locals }) => { const image = formData.get('image') as File; const name = formData.get('name')?.toString().trim(); + console.log('[API:ImgAdd] Form data:', { + hasImage: !!image, + imageSize: image?.size, + imageName: image?.name, + imageType: image?.type, + recipeName: name + }); + if (!image) { + console.error('[API:ImgAdd] No image file provided'); throw error(400, 'No image file provided'); } if (!name) { + console.error('[API:ImgAdd] Image name is required'); throw error(400, 'Image name is required'); } // Comprehensive security validation + console.log('[API:ImgAdd] Starting validation...'); const validationResult = await validateImageFile(image); if (!validationResult.valid) { + console.error('[API:ImgAdd] Validation failed:', validationResult.error); throw error(400, validationResult.error || 'Invalid image file'); } + console.log('[API:ImgAdd] Validation passed'); // Convert File to Buffer for processing const arrayBuffer = await image.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + console.log('[API:ImgAdd] Buffer created, size:', buffer.length, 'bytes'); // Generate content hash for cache busting const imageHash = generateImageHashFromBuffer(buffer); const hashedFilename = getHashedFilename(name, imageHash); const unhashedFilename = name + '.webp'; + console.log('[API:ImgAdd] Generated filenames:', { + hashed: hashedFilename, + unhashed: unhashedFilename + }); // Process image with Sharp - convert to WebP format // Save full size - both hashed and unhashed versions + console.log('[API:ImgAdd] Processing full size image...'); const fullBuffer = await sharp(buffer) .toFormat('webp') .webp({ quality: 90 }) // High quality for full size .toBuffer(); + console.log('[API:ImgAdd] Full size buffer created, size:', fullBuffer.length, 'bytes'); - await sharp(fullBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'full', hashedFilename) - ); - await sharp(fullBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'full', unhashedFilename) - ); + const fullHashedPath = path.join(IMAGE_DIR, 'rezepte', 'full', hashedFilename); + const fullUnhashedPath = path.join(IMAGE_DIR, 'rezepte', 'full', unhashedFilename); + console.log('[API:ImgAdd] Saving full size to:', { fullHashedPath, fullUnhashedPath }); + + await sharp(fullBuffer).toFile(fullHashedPath); + await sharp(fullBuffer).toFile(fullUnhashedPath); + console.log('[API:ImgAdd] Full size images saved ✓'); // Save thumbnail (800px width) - both hashed and unhashed versions + console.log('[API:ImgAdd] Processing thumbnail...'); const thumbBuffer = await sharp(buffer) .resize({ width: 800 }) .toFormat('webp') .webp({ quality: 85 }) .toBuffer(); + console.log('[API:ImgAdd] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes'); - await sharp(thumbBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'thumb', hashedFilename) - ); - await sharp(thumbBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'thumb', unhashedFilename) - ); + const thumbHashedPath = path.join(IMAGE_DIR, 'rezepte', 'thumb', hashedFilename); + const thumbUnhashedPath = path.join(IMAGE_DIR, 'rezepte', 'thumb', unhashedFilename); + console.log('[API:ImgAdd] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath }); + + await sharp(thumbBuffer).toFile(thumbHashedPath); + await sharp(thumbBuffer).toFile(thumbUnhashedPath); + console.log('[API:ImgAdd] Thumbnail images saved ✓'); // Save placeholder (20px width) - both hashed and unhashed versions + console.log('[API:ImgAdd] Processing placeholder...'); const placeholderBuffer = await sharp(buffer) .resize({ width: 20 }) .toFormat('webp') .webp({ quality: 60 }) .toBuffer(); + console.log('[API:ImgAdd] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes'); - await sharp(placeholderBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename) - ); - await sharp(placeholderBuffer).toFile( - path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename) - ); + const placeholderHashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename); + const placeholderUnhashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename); + console.log('[API:ImgAdd] Saving placeholder to:', { placeholderHashedPath, placeholderUnhashedPath }); + await sharp(placeholderBuffer).toFile(placeholderHashedPath); + await sharp(placeholderBuffer).toFile(placeholderUnhashedPath); + console.log('[API:ImgAdd] Placeholder images saved ✓'); + + console.log('[API:ImgAdd] Upload completed successfully ✓'); return json({ success: true, msg: 'Image uploaded successfully', @@ -108,7 +140,7 @@ export const POST = (async ({ request, locals }) => { if (err.status) throw err; // Log and throw generic error for unexpected failures - console.error('Image upload error:', err); + console.error('[API:ImgAdd] Upload error:', err); throw error(500, `Failed to upload image: ${err.message || 'Unknown error'}`); } }) satisfies RequestHandler; diff --git a/src/utils/imageProcessing.ts b/src/utils/imageProcessing.ts index b45dc96..0960ffa 100644 --- a/src/utils/imageProcessing.ts +++ b/src/utils/imageProcessing.ts @@ -15,63 +15,86 @@ export async function processAndSaveRecipeImage( name: string, imageDir: string ): Promise<{ filename: string; unhashedFilename: string }> { + console.log('[ImageProcessing] Starting image processing for:', { + fileName: file.name, + recipeName: name, + imageDir: imageDir + }); + // Comprehensive security validation const validationResult = await validateImageFile(file); if (!validationResult.valid) { + console.error('[ImageProcessing] Validation failed:', validationResult.error); throw new Error(validationResult.error || 'Invalid image file'); } + console.log('[ImageProcessing] Validation succeeded'); // Convert File to Buffer for processing const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + console.log('[ImageProcessing] Buffer created, size:', buffer.length, 'bytes'); // Generate content hash for cache busting const imageHash = generateImageHashFromBuffer(buffer); const hashedFilename = getHashedFilename(name, imageHash); const unhashedFilename = name + '.webp'; + console.log('[ImageProcessing] Generated filenames:', { + hashed: hashedFilename, + unhashed: unhashedFilename + }); // Process image with Sharp - convert to WebP format // Save full size - both hashed and unhashed versions + console.log('[ImageProcessing] Converting to WebP and generating full size...'); const fullBuffer = await sharp(buffer) .toFormat('webp') .webp({ quality: 90 }) // High quality for full size .toBuffer(); + console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes'); - await sharp(fullBuffer).toFile( - path.join(imageDir, 'rezepte', 'full', hashedFilename) - ); - await sharp(fullBuffer).toFile( - path.join(imageDir, 'rezepte', 'full', unhashedFilename) - ); + const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); + const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); + console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath }); + + await sharp(fullBuffer).toFile(fullHashedPath); + await sharp(fullBuffer).toFile(fullUnhashedPath); + console.log('[ImageProcessing] Full size images saved ✓'); // Save thumbnail (800px width) - both hashed and unhashed versions + console.log('[ImageProcessing] Generating thumbnail (800px)...'); const thumbBuffer = await sharp(buffer) .resize({ width: 800 }) .toFormat('webp') .webp({ quality: 85 }) .toBuffer(); + console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes'); - await sharp(thumbBuffer).toFile( - path.join(imageDir, 'rezepte', 'thumb', hashedFilename) - ); - await sharp(thumbBuffer).toFile( - path.join(imageDir, 'rezepte', 'thumb', unhashedFilename) - ); + const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename); + const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename); + console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath }); + + await sharp(thumbBuffer).toFile(thumbHashedPath); + await sharp(thumbBuffer).toFile(thumbUnhashedPath); + console.log('[ImageProcessing] Thumbnail images saved ✓'); // Save placeholder (20px width) - both hashed and unhashed versions + console.log('[ImageProcessing] Generating placeholder (20px)...'); const placeholderBuffer = await sharp(buffer) .resize({ width: 20 }) .toFormat('webp') .webp({ quality: 60 }) .toBuffer(); + console.log('[ImageProcessing] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes'); - await sharp(placeholderBuffer).toFile( - path.join(imageDir, 'rezepte', 'placeholder', hashedFilename) - ); - await sharp(placeholderBuffer).toFile( - path.join(imageDir, 'rezepte', 'placeholder', unhashedFilename) - ); + const placeholderHashedPath = path.join(imageDir, 'rezepte', 'placeholder', hashedFilename); + const placeholderUnhashedPath = path.join(imageDir, 'rezepte', 'placeholder', unhashedFilename); + console.log('[ImageProcessing] Saving placeholder to:', { placeholderHashedPath, placeholderUnhashedPath }); + await sharp(placeholderBuffer).toFile(placeholderHashedPath); + await sharp(placeholderBuffer).toFile(placeholderUnhashedPath); + console.log('[ImageProcessing] Placeholder images saved ✓'); + + console.log('[ImageProcessing] All image versions processed and saved successfully ✓'); return { filename: hashedFilename, unhashedFilename: unhashedFilename diff --git a/src/utils/imageValidation.ts b/src/utils/imageValidation.ts index 0abbfa9..f6347df 100644 --- a/src/utils/imageValidation.ts +++ b/src/utils/imageValidation.ts @@ -34,8 +34,15 @@ const MAGIC_BYTES = { * @returns ValidationResult with valid flag and optional error message */ export async function validateImageFile(file: File): Promise { + console.log('[ImageValidation] Starting validation for file:', { + name: file.name, + size: file.size, + type: file.type + }); + // Layer 1: Check file size if (file.size > MAX_FILE_SIZE) { + console.error('[ImageValidation] File too large:', file.size, 'bytes'); 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` @@ -43,6 +50,7 @@ export async function validateImageFile(file: File): Promise { } if (file.size === 0) { + console.error('[ImageValidation] File is empty'); return { valid: false, error: 'File is empty' @@ -51,20 +59,24 @@ export async function validateImageFile(file: File): Promise { // Layer 2: Check MIME type (client-provided) if (!ALLOWED_MIME_TYPES.includes(file.type)) { + console.error('[ImageValidation] Invalid MIME type:', file.type); return { valid: false, error: `Invalid file type. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}. Received: ${file.type || 'unknown'}` }; } + console.log('[ImageValidation] MIME type valid:', file.type); // Layer 3: Check file extension const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !ALLOWED_EXTENSIONS.includes(extension)) { + console.error('[ImageValidation] Invalid extension:', extension); return { valid: false, error: `Invalid file extension. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}. Received: ${extension || 'none'}` }; } + console.log('[ImageValidation] Extension valid:', extension); // Convert File to Buffer for magic bytes validation const arrayBuffer = await file.arrayBuffer(); @@ -75,14 +87,18 @@ export async function validateImageFile(file: File): Promise { const fileType = await fileTypeFromBuffer(buffer); if (!fileType) { + console.error('[ImageValidation] Unable to detect file type from headers'); return { valid: false, error: 'Unable to detect file type from file headers. File may be corrupted or not a valid image.' }; } + console.log('[ImageValidation] Detected file type from headers:', fileType.mime); + // Verify detected type matches allowed types if (!ALLOWED_MIME_TYPES.includes(fileType.mime)) { + console.error('[ImageValidation] Detected type not allowed:', 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.` @@ -91,12 +107,18 @@ export async function validateImageFile(file: File): Promise { // Verify MIME type consistency if (fileType.mime !== file.type) { + console.error('[ImageValidation] MIME type mismatch:', { + claimed: file.type, + actual: fileType.mime + }); return { valid: false, error: `File type mismatch: claimed to be "${file.type}" but actual type is "${fileType.mime}". Possible file spoofing attempt.` }; } + console.log('[ImageValidation] Magic bytes validation passed'); } catch (error) { + console.error('[ImageValidation] Magic bytes validation error:', error); return { valid: false, error: `Failed to validate file headers: ${error.message}` @@ -107,7 +129,14 @@ export async function validateImageFile(file: File): Promise { try { const metadata = await sharp(buffer).metadata(); + console.log('[ImageValidation] Sharp metadata:', { + width: metadata.width, + height: metadata.height, + format: metadata.format + }); + if (!metadata.width || !metadata.height) { + console.error('[ImageValidation] Unable to read image dimensions'); return { valid: false, error: 'Invalid image: unable to read image dimensions' @@ -115,18 +144,25 @@ export async function validateImageFile(file: File): Promise { } if (metadata.width > 10000 || metadata.height > 10000) { + console.error('[ImageValidation] Image dimensions too large:', { + width: metadata.width, + height: metadata.height + }); return { valid: false, error: `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum: 10000x10000px` }; } + console.log('[ImageValidation] Sharp validation passed'); } catch (error) { + console.error('[ImageValidation] Sharp validation error:', error); return { valid: false, error: `Invalid or corrupted image file: ${error.message}` }; } + console.log('[ImageValidation] All validation layers passed ✓'); return { valid: true }; }