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