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 deac9e3d1f
commit 0a49e20c02
12 changed files with 1777 additions and 866 deletions

View File

@@ -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;
}
</style>
@@ -362,11 +453,15 @@ input::placeholder{
{/if}
<label class=img_label for=img_picker>
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
<label class=img_label for=img_picker style={uploading ? 'opacity: 0.5; cursor: not-allowed;' : ''}>
{#if uploading}
<div class="upload-spinner">Uploading...</div>
{:else}
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
{/if}
</label>
</div>
<input type="file" id=img_picker accept="image/webp image/jpeg" onchange={show_local_image}>
<input type="file" id=img_picker accept="image/webp,image/jpeg,image/jpg,image/png" onchange={show_local_image} disabled={uploading}>
<div class=title>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>