From 0a49e20c022fc12f48f72b13fd1016fdad77374b Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 13 Jan 2026 14:21:15 +0100 Subject: [PATCH] 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) --- package.json | 9 +- src/lib/components/CardAdd.svelte | 155 +++- .../components/CreateIngredientList.svelte | 56 +- src/lib/components/CreateStepList.svelte | 50 +- src/lib/components/TranslationApproval.svelte | 121 +-- .../add/+page.server.ts | 114 ++- .../[recipeLang=recipeLang]/add/+page.svelte | 403 +++++---- .../edit/[name]/+page.server.ts | 181 +++- .../edit/[name]/+page.svelte | 818 ++++++++---------- src/routes/api/rezepte/img/add/+server.ts | 152 ++-- src/utils/imageValidation.ts | 213 +++++ src/utils/recipeFormHelpers.ts | 371 ++++++++ 12 files changed, 1777 insertions(+), 866 deletions(-) create mode 100644 src/utils/imageValidation.ts create mode 100644 src/utils/recipeFormHelpers.ts diff --git a/package.json b/package.json index 0d9fc0d..f8fd40b 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,15 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "test:e2e:docker:up": "docker compose -f docker-compose.test.yml up -d", + "test:e2e:docker:down": "docker compose -f docker-compose.test.yml down -v", + "test:e2e:docker": "docker compose -f docker-compose.test.yml up -d && playwright test; docker compose -f docker-compose.test.yml down -v", + "test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'" }, "packageManager": "pnpm@9.0.0", "devDependencies": { - "@playwright/test": "^1.56.1", + "@playwright/test": "1.56.1", "@sveltejs/adapter-auto": "^6.1.0", "@sveltejs/kit": "^2.37.0", "@sveltejs/vite-plugin-svelte": "^6.1.3", @@ -40,6 +44,7 @@ "@sveltejs/adapter-node": "^5.0.0", "chart.js": "^4.5.0", "cheerio": "1.0.0-rc.12", + "file-type": "^19.0.0", "ioredis": "^5.9.0", "mongoose": "^8.0.0", "node-cron": "^4.2.1", diff --git a/src/lib/components/CardAdd.svelte b/src/lib/components/CardAdd.svelte index 91f2f96..7ddaff4 100644 --- a/src/lib/components/CardAdd.svelte +++ b/src/lib/components/CardAdd.svelte @@ -5,48 +5,134 @@ import "$lib/css/shake.css" import "$lib/css/icon.css" import { onMount } from 'svelte' -let { card_data = $bindable(), image_preview_url = $bindable() } = $props<{ card_data: any, image_preview_url: string }>(); +let { + card_data = $bindable(), + image_preview_url = $bindable(), + uploaded_image_filename = $bindable(''), + short_name = '' +} = $props<{ + card_data: any, + image_preview_url: string, + uploaded_image_filename?: string, + short_name: string +}>(); -onMount( () => { - fetch(image_preview_url, { method: 'HEAD' }) - .then(response => { - if(response.redirected){ - image_preview_url = "" - } - }) +// Check if image redirects to placeholder by attempting to load it +onMount(() => { + if (image_preview_url) { + const img = new Image(); + + img.onload = () => { + // Check if this is the placeholder image (150x150) + if (img.naturalWidth === 150 && img.naturalHeight === 150) { + console.log('Detected placeholder image (150x150), clearing preview'); + image_preview_url = "" + } else { + console.log('Real image loaded:', { + url: image_preview_url, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight + }); + } + }; + + img.onerror = () => { + // Image failed to load - could be 404 or network error + console.log('Image failed to load, clearing preview'); + image_preview_url = "" + }; + + img.src = image_preview_url; + } }) -import { img } from '$lib/js/img_store'; - if(!card_data.tags){ card_data.tags = [] } - //locals let new_tag = $state(""); +let uploading = $state(false); +let upload_error = $state(""); +/** + * Handles image file selection and upload + * Now uses FormData instead of base64 encoding for better security and performance + */ +export async function show_local_image(){ + const file = this.files[0]; + if (!file) return; -export function show_local_image(){ - var file = this.files[0] - // allowed MIME types - var mime_types = [ 'image/webp' ]; + // Client-side validation + const allowed_mime_types = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png']; + const max_size = 5 * 1024 * 1024; // 5MB - // validate MIME - if(mime_types.indexOf(file.type) == -1) { - alert('Error : Incorrect file type'); - return; - } - image_preview_url = URL.createObjectURL(file); - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = e => { - img.update(() => e.target.result.split(',')[1]); - }; + // Validate MIME type + if(!allowed_mime_types.includes(file.type)) { + upload_error = 'Invalid file type. Please upload a JPEG, PNG, or WebP image.'; + alert(upload_error); + return; + } + + // Validate file size + if(file.size > max_size) { + upload_error = `File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`; + alert(upload_error); + return; + } + + // Show preview immediately + image_preview_url = URL.createObjectURL(file); + upload_error = ""; + + // Upload to server + try { + uploading = true; + + // Validate short_name is provided + if (!short_name || short_name.trim() === '') { + upload_error = 'Please provide a short name (URL) before uploading an image.'; + alert(upload_error); + uploading = false; + return; + } + + // Create FormData for upload + const formData = new FormData(); + formData.append('image', file); + formData.append('name', short_name.trim()); + + const response = await fetch('/api/rezepte/img/add', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const error_data = await response.json(); + throw new Error(error_data.message || 'Upload failed'); + } + + const result = await response.json(); + uploaded_image_filename = result.unhashedFilename; + upload_error = ""; + + } catch (error: any) { + console.error('Image upload error:', error); + upload_error = error.message || 'Failed to upload image. Please try again.'; + alert(`Upload failed: ${upload_error}`); + + // Clear preview on error + image_preview_url = ""; + uploaded_image_filename = ""; + } finally { + uploading = false; + } } export function remove_selected_images(){ - image_preview_url = "" + image_preview_url = ""; + uploaded_image_filename = ""; + upload_error = ""; } @@ -344,6 +430,11 @@ input::placeholder{ .tag_input{ width: 12ch; } +.upload-spinner { + color: white; + font-size: 1.2rem; + font-weight: bold; +} @@ -362,11 +453,15 @@ input::placeholder{ {/if} -