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

@@ -1,44 +1,52 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import Check from '$lib/assets/icons/Check.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
import CardAdd from '$lib/components/CardAdd.svelte';
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/CreateStepList.svelte';
import '$lib/css/action_button.css';
import '$lib/css/nordtheme.css';
let preamble = ""
let addendum = ""
let { data, form }: { data: PageData; form: ActionData } = $props();
// Recipe data state
let preamble = $state("");
let addendum = $state("");
let image_preview_url = $state("");
let uploaded_image_filename = $state("");
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = null;
let showTranslationWorkflow = $state(false);
let translationData: any = $state(null);
// Season store
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
import { img } from '$lib/js/img_store';
season.update(() => [])
let season_local
season.update(() => []);
let season_local = $state<number[]>([]);
season.subscribe((s) => {
season_local = s
season_local = s;
});
let portions_local
portions.update(() => "")
let portions_local = $state("");
portions.update(() => "");
portions.subscribe((p) => {
portions_local = p});
let img_local
img.update(() => "")
img.subscribe((i) => {
img_local = i});
portions_local = p;
});
export let card_data ={
let card_data = $state({
icon: "",
category: "",
name: "",
description: "",
tags: [],
}
export let add_info ={
tags: [] as string[],
});
let add_info = $state({
preparation: "",
fermentation: {
bulk: "",
@@ -51,70 +59,43 @@
},
total_time: "",
cooking: "",
}
});
let images = []
let short_name = ""
let datecreated = new Date()
let datemodified = datecreated
let isBaseRecipe = false
let short_name = $state("");
let isBaseRecipe = $state(false);
let ingredients = $state<any[]>([]);
let instructions = $state<any[]>([]);
import type { PageData } from './$types';
import CardAdd from '$lib/components/CardAdd.svelte';
// Form submission state
let submitting = $state(false);
let formElement: HTMLFormElement;
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
export let ingredients = []
import CreateStepList from '$lib/components/CreateStepList.svelte';
export let instructions = []
function get_season(){
let season = []
// Get season data from checkboxes
function get_season(): number[] {
const season: number[] = [];
const el = document.getElementById("labels");
for(var i = 0; i < el.children.length; i++){
if(el.children[i].children[0].children[0].checked){
season.push(i+1)
if (!el) return season;
for (let i = 0; i < el.children.length; i++) {
const checkbox = el.children[i].children[0].children[0] as HTMLInputElement;
if (checkbox?.checked) {
season.push(i + 1);
}
}
return season
}
function write_season(season){
const el = document.getElementById("labels");
for(var i = 0; i < season.length; i++){
el.children[i].children[0].children[0].checked = true
}
return season;
}
async function upload_img(){
console.log("uploading...")
console.log(img_local)
const data = {
image: img_local,
name: short_name.trim(),
}
await fetch(`/api/rezepte/img/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify(data)
});
}
// Prepare the German recipe data
// Prepare German recipe data
function getGermanRecipeData() {
return {
...card_data,
...add_info,
images: [{mediapath: short_name.trim() + '.webp', alt: "", caption: ""}],
images: uploaded_image_filename ? [{ mediapath: uploaded_image_filename, alt: "", caption: "" }] : [],
season: season_local,
short_name : short_name.trim(),
short_name: short_name.trim(),
portions: portions_local,
datecreated,
datemodified,
datecreated: new Date(),
datemodified: new Date(),
instructions,
ingredients,
preamble,
@@ -125,7 +106,7 @@
// Show translation workflow before submission
function prepareSubmit() {
// Validate required fields
// Client-side validation
if (!short_name.trim()) {
alert('Bitte geben Sie einen Kurznamen ein');
return;
@@ -142,16 +123,24 @@
}, 100);
}
// Handle translation approval
// Handle translation approval - populate form and submit
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doPost();
// Submit the form programmatically
if (formElement) {
formElement.requestSubmit();
}
}
// Handle translation skipped
// Handle translation skipped - submit without translation
function handleTranslationSkipped() {
translationData = null;
doPost();
// Submit the form programmatically
if (formElement) {
formElement.requestSubmit();
}
}
// Handle translation cancelled
@@ -160,51 +149,16 @@
translationData = null;
}
// Actually submit the recipe
async function doPost () {
upload_img()
console.log(add_info.total_time)
const recipeData = getGermanRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: [],
};
// Display form errors if any
$effect(() => {
if (form?.error) {
alert(`Fehler: ${form.error}`);
}
const res = await fetch('/api/rezepte/add', {
method: 'POST',
body: JSON.stringify({
recipe: recipeData,
}),
headers: {
'content-type': 'application/json',
}
});
if(res.status === 200){
const url = location.href.split('/')
url.splice(url.length -1, 1);
url.push(short_name)
location.assign(url.join('/'))
}
else{
const item = await res.json();
alert(item.message)
}
}
});
</script>
<style>
input{
input {
display: block;
border: unset;
margin: 1rem auto;
@@ -213,14 +167,12 @@ input{
background-color: var(--nord4);
font-size: 1.1rem;
transition: 100ms;
}
input:hover,
input:focus-visible
{
input:focus-visible {
scale: 1.05 1.05;
}
.list_wrapper{
.list_wrapper {
margin-inline: auto;
display: flex;
flex-direction: row;
@@ -228,22 +180,22 @@ input:focus-visible
gap: 2rem;
justify-content: center;
}
@media screen and (max-width: 700px){
.list_wrapper{
@media screen and (max-width: 700px) {
.list_wrapper {
flex-direction: column;
}
}
h1{
h1 {
text-align: center;
margin-bottom: 2rem;
}
.title_container{
.title_container {
max-width: 1000px;
display: flex;
flex-direction: column;
margin-inline: auto;
}
.title{
.title {
position: relative;
width: min(800px, 80vw);
margin-block: 2rem;
@@ -251,7 +203,7 @@ h1{
background-color: var(--nord6);
padding: 1rem 2rem;
}
.title p{
.title p {
border: 2px solid var(--nord1);
border-radius: 10000px;
padding: 0.5em 1em;
@@ -259,10 +211,10 @@ h1{
transition: 200ms;
}
.title p:hover,
.title p:focus-within{
.title p:focus-within {
scale: 1.02 1.02;
}
.addendum{
.addendum {
font-size: 1.1rem;
max-width: 90%;
margin-inline: auto;
@@ -272,23 +224,22 @@ h1{
transition: 100ms;
}
.addendum:hover,
.addendum:focus-within
{
.addendum:focus-within {
scale: 1.02 1.02;
}
.addendum_wrapper{
.addendum_wrapper {
max-width: 1000px;
margin-inline: auto;
}
h3{
h3 {
text-align: center;
}
button.action_button{
button.action_button {
animation: unset !important;
font-size: 1.3rem;
color: white;
}
.submit_buttons{
.submit_buttons {
display: flex;
margin-inline: auto;
max-width: 1000px;
@@ -297,17 +248,27 @@ button.action_button{
align-items: center;
gap: 2rem;
}
.submit_buttons p{
.submit_buttons p {
padding: 0;
padding-right: 0.5em;
margin: 0;
}
@media (prefers-color-scheme: dark){
.title{
@media (prefers-color-scheme: dark) {
.title {
background-color: var(--nord6-dark);
}
}
.error-message {
background: var(--nord11);
color: var(--nord6);
padding: 1rem;
border-radius: 4px;
margin: 1rem auto;
max-width: 800px;
text-align: center;
}
</style>
<svelte:head>
<title>Rezept erstellen</title>
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
@@ -315,57 +276,119 @@ button.action_button{
<h1>Rezept erstellen</h1>
<CardAdd {card_data}></CardAdd>
<h3>Kurzname (für URL):</h3>
<input bind:value={short_name} placeholder="Kurzname"/>
<div style="text-align: center; margin: 1rem;">
<label style="font-size: 1.1rem; cursor: pointer;">
<input type="checkbox" bind:checked={isBaseRecipe} style="width: auto; display: inline; margin-right: 0.5em;" />
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
</label>
</div>
<div class=title_container>
<div class=title>
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<div class=tags>
<h4>Saison:</h4>
<SeasonSelect></SeasonSelect>
</div>
</div>
</div>
<div class=list_wrapper>
<div>
<CreateIngredientList {ingredients}></CreateIngredientList>
</div>
<div>
<CreateStepList {instructions} {add_info}></CreateStepList>
</div>
</div>
<div class=addendum_wrapper>
<h3>Nachtrag:</h3>
<div class=addendum bind:innerText={addendum} contenteditable></div>
</div>
{#if !showTranslationWorkflow}
<div class=submit_buttons>
<button class=action_button onclick={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{#if form?.error}
<div class="error-message">
<strong>Fehler:</strong> {form.error}
</div>
{/if}
<form
method="POST"
bind:this={formElement}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<!-- Hidden inputs for complex nested data -->
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
<input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} />
<input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} />
<input type="hidden" name="season" value={JSON.stringify(season_local)} />
<input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} />
<input type="hidden" name="uploaded_image_filename" value={uploaded_image_filename} />
<!-- Translation data (added after approval) -->
{#if translationData}
<input type="hidden" name="translation_json" value={JSON.stringify(translationData)} />
<input type="hidden" name="translation_metadata_json" value={JSON.stringify({
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: []
})} />
{/if}
<CardAdd
bind:card_data
bind:image_preview_url
bind:uploaded_image_filename
short_name={short_name}
/>
<h3>Kurzname (für URL):</h3>
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
<!-- Hidden inputs for card data -->
<input type="hidden" name="name" value={card_data.name} />
<input type="hidden" name="description" value={card_data.description} />
<input type="hidden" name="category" value={card_data.category} />
<input type="hidden" name="icon" value={card_data.icon} />
<input type="hidden" name="portions" value={portions_local} />
<div style="text-align: center; margin: 1rem;">
<label style="font-size: 1.1rem; cursor: pointer;">
<input
type="checkbox"
name="isBaseRecipe"
bind:checked={isBaseRecipe}
style="width: auto; display: inline; margin-right: 0.5em;"
/>
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
</label>
</div>
<div class="title_container">
<div class="title">
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<input type="hidden" name="preamble" value={preamble} />
<div class="tags">
<h4>Saison:</h4>
<SeasonSelect />
</div>
</div>
</div>
<div class="list_wrapper">
<div>
<CreateIngredientList bind:ingredients />
</div>
<div>
<CreateStepList bind:instructions bind:add_info />
</div>
</div>
<div class="addendum_wrapper">
<h3>Nachtrag:</h3>
<div class="addendum" bind:innerText={addendum} contenteditable></div>
<input type="hidden" name="addendum" value={addendum} />
</div>
{#if !showTranslationWorkflow}
<div class="submit_buttons">
<button
type="button"
class="action_button"
onclick={prepareSubmit}
disabled={submitting}
>
<p>Weiter zur Übersetzung</p>
<Check fill="white" width="2rem" height="2rem" />
</button>
</div>
{/if}
</form>
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getGermanRecipeData()}
onapproved={handleTranslationApproved}
onskipped={handleTranslationSkipped}
oncancelled={handleTranslationCancelled}
/>
</div>
<div id="translation-section">
<TranslationApproval
germanData={getGermanRecipeData()}
onapproved={handleTranslationApproved}
onskipped={handleTranslationSkipped}
oncancelled={handleTranslationCancelled}
/>
</div>
{/if}