Răsfoiți Sursa

Merge branch 'small-fixes-in-ui' of Medowar/feuerwehr-freising-antragsformular into main

importing last fixes before testing
Medowar 1 lună în urmă
părinte
comite
5a289b241e

+ 4 - 1
.htaccess

@@ -7,7 +7,10 @@ RewriteEngine On
 RewriteRule "(^|/)\." - [F,L]
 
 # Block internal directories from public access
-RewriteRule "^(config|src|storage|bin|docs|lib)(/|$)" - [F,L,NC]
+RewriteRule "^(config|src|storage|docs|lib)(/|$)" - [F,L,NC]
+
+# Block admin-internal scripts (CLI tools + auth class file)
+RewriteRule "^admin/(auth|cleanup|test-mail)\.php$" - [F,L,NC]
 
 # Keep direct file-based endpoints, fallback unknown routes to index
 RewriteCond %{REQUEST_FILENAME} !-f

+ 1 - 1
admin/application.php

@@ -50,7 +50,7 @@ $csrf = Csrf::token();
 <header class="site-header">
     <div class="container header-inner">
         <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('admin/index.php')) ?>">
-            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-Logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
             <div class="brand-title"><?= htmlspecialchars((string) ($app['project_name'] ?? 'Admin')) ?></div>
         </a>
     </div>

+ 5 - 0
src/Admin/Auth.php → admin/auth.php

@@ -6,6 +6,11 @@ namespace App\Admin;
 
 use App\App\Bootstrap;
 
+if (realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
+    http_response_code(404);
+    exit;
+}
+
 final class Auth
 {
     /** @var array<string, mixed> */

+ 5 - 0
bin/cleanup.php → admin/cleanup.php

@@ -6,6 +6,11 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Storage\FileSystem;
 
+if (PHP_SAPI !== 'cli') {
+    http_response_code(403);
+    exit('Forbidden');
+}
+
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init(false);
 

+ 1 - 1
admin/index.php

@@ -48,7 +48,7 @@ if ($query !== '') {
 <header class="site-header">
     <div class="container header-inner">
         <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('admin/index.php')) ?>">
-            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-Logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
             <div class="brand-title"><?= htmlspecialchars((string) ($app['project_name'] ?? 'Admin')) ?></div>
         </a>
     </div>

+ 1 - 1
admin/login.php

@@ -53,7 +53,7 @@ $csrf = Csrf::token();
 <header class="site-header">
     <div class="container header-inner">
         <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('admin/login.php')) ?>">
-            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-Logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
             <div class="brand-title"><?= htmlspecialchars((string) ($app['project_name'] ?? 'Admin')) ?></div>
         </a>
     </div>

+ 6 - 1
bin/test-mail.php → admin/test-mail.php

@@ -2,7 +2,7 @@
 
 /**
  * CLI test script for mail rendering and PDF generation.
- * Run: php bin/test-mail.php
+ * Run: php admin/test-mail.php
  *
  * Writes output files to storage/logs/ for inspection.
  * Does NOT send actual emails.
@@ -10,6 +10,11 @@
 
 declare(strict_types=1);
 
+if (PHP_SAPI !== 'cli') {
+    http_response_code(403);
+    exit('Forbidden');
+}
+
 require_once __DIR__ . '/../src/autoload.php';
 
 use App\App\Bootstrap;

+ 173 - 0
api/delete-upload.php

@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+use App\Storage\FileSystem;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+}
+
+$csrf = $_POST['csrf'] ?? '';
+if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+}
+
+if (trim((string) ($_POST['website'] ?? '')) !== '') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+}
+
+$email = strtolower(trim((string) ($_POST['email'] ?? '')));
+if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+}
+
+$field = trim((string) ($_POST['field'] ?? ''));
+$index = (int) ($_POST['index'] ?? -1);
+if ($field === '' || $index < 0) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiger Upload-Eintrag.'], 422);
+}
+
+/** @return array{path: string, dir: string}|null */
+function resolveStoredUploadPath(array $entry, array $app): ?array
+{
+    $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
+    if ($baseDir === '') {
+        return null;
+    }
+
+    $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
+    $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
+    if ($storedDir === '' || $storedFilename === '') {
+        return null;
+    }
+    if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
+        return null;
+    }
+    if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
+        return null;
+    }
+    if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
+        return null;
+    }
+
+    $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
+    $dir = dirname($path);
+
+    $realBase = realpath($baseDir);
+    if ($realBase !== false) {
+        $realDir = realpath($dir);
+        $realPath = realpath($path);
+        if ($realDir !== false && !str_starts_with($realDir, $realBase)) {
+            return null;
+        }
+        if ($realPath !== false && !str_starts_with($realPath, $realBase)) {
+            return null;
+        }
+    }
+
+    return ['path' => $path, 'dir' => $dir];
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('delete-upload:%s:%s', $ip, $email);
+if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429);
+}
+
+$store = new JsonStore();
+
+try {
+    $result = $store->withEmailLock($email, static function () use ($store, $app, $email, $field, $index): array {
+        if ($store->hasSubmission($email)) {
+            return [
+                'ok' => false,
+                'status' => 409,
+                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            ];
+        }
+
+        $draft = $store->getDraft($email);
+        if (!is_array($draft)) {
+            return [
+                'ok' => false,
+                'status' => 404,
+                'message' => 'Kein Entwurf gefunden.',
+            ];
+        }
+
+        $uploads = (array) ($draft['uploads'] ?? []);
+        $files = $uploads[$field] ?? null;
+        if (!is_array($files) || !isset($files[$index]) || !is_array($files[$index])) {
+            return [
+                'ok' => false,
+                'status' => 404,
+                'message' => 'Upload nicht gefunden.',
+            ];
+        }
+
+        $entry = $files[$index];
+        unset($files[$index]);
+        $files = array_values($files);
+        if ($files === []) {
+            unset($uploads[$field]);
+        } else {
+            $uploads[$field] = $files;
+        }
+
+        $updatedDraft = $store->replaceDraft($email, [
+            'step' => $draft['step'] ?? 1,
+            'form_data' => (array) ($draft['form_data'] ?? []),
+            'uploads' => $uploads,
+        ]);
+
+        $resolved = resolveStoredUploadPath($entry, $app);
+        if ($resolved !== null) {
+            $fullPath = $resolved['path'];
+            if (is_file($fullPath)) {
+                @unlink($fullPath);
+            }
+
+            $entryDir = $resolved['dir'];
+            if (is_dir($entryDir)) {
+                $remaining = scandir($entryDir);
+                if (is_array($remaining) && count($remaining) <= 2) {
+                    FileSystem::removeTree($entryDir);
+                }
+            }
+        }
+
+        return [
+            'ok' => true,
+            'status' => 200,
+            'uploads' => $updatedDraft['uploads'] ?? [],
+            'updated_at' => $updatedDraft['updated_at'] ?? null,
+        ];
+    });
+} catch (Throwable $e) {
+    Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage());
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Upload konnte nicht gelöscht werden.'], 500);
+}
+
+if (($result['ok'] ?? false) !== true) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($result['message'] ?? 'Upload konnte nicht gelöscht werden.'),
+    ], (int) ($result['status'] ?? 422));
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Upload gelöscht.',
+    'uploads' => $result['uploads'] ?? [],
+    'updated_at' => $result['updated_at'] ?? null,
+]);

+ 18 - 2
api/reset.php

@@ -46,8 +46,13 @@ try {
         $hadSubmission = $submission !== null;
 
         if ($hadSubmission) {
-            $submissionKey = (string) ($submission['application_key'] ?? $store->emailKey($email));
-            $store->deleteSubmissionByKey($submissionKey);
+            return [
+                'ok' => false,
+                'status' => 409,
+                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
+                'had_draft' => $hadDraft,
+                'had_submission' => true,
+            ];
         }
 
         $store->deleteDraft($email);
@@ -56,6 +61,8 @@ try {
         FileSystem::removeTree($uploadDir);
 
         return [
+            'ok' => true,
+            'status' => 200,
             'had_draft' => $hadDraft,
             'had_submission' => $hadSubmission,
         ];
@@ -65,6 +72,15 @@ try {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Daten konnten nicht gelöscht werden.'], 500);
 }
 
+if (($result['ok'] ?? false) !== true) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($result['message'] ?? 'Daten konnten nicht gelöscht werden.'),
+        'had_draft' => (bool) ($result['had_draft'] ?? false),
+        'had_submission' => (bool) ($result['had_submission'] ?? false),
+    ], (int) ($result['status'] ?? 422));
+}
+
 Bootstrap::jsonResponse([
     'ok' => true,
     'message' => 'Gespeicherte Daten wurden gelöscht.',

+ 20 - 1
api/submit.php

@@ -14,6 +14,25 @@ use App\Security\RateLimiter;
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
+/** @param array<string, mixed> $app */
+function resolveSubmitSuccessMessage(array $app): string
+{
+    $fallback = 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.';
+    $configured = trim((string) ($app['submission_success_message'] ?? $fallback));
+    if ($configured === '') {
+        $configured = $fallback;
+    }
+
+    $contactEmail = trim((string) ($app['contact_email'] ?? ''));
+    $message = str_replace(
+        ['%contact_email%', '{{contact_email}}'],
+        $contactEmail !== '' ? $contactEmail : 'uns',
+        $configured
+    );
+
+    return trim($message);
+}
+
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
 }
@@ -141,6 +160,6 @@ $mailer->sendSubmissionMails($submission);
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Antrag erfolgreich übermittelt.',
+    'message' => resolveSubmitSuccessMessage($app),
     'application_key' => $submission['application_key'] ?? null,
 ]);

+ 132 - 0
api/upload-preview.php

@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+    http_response_code(405);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Method not allowed';
+    exit;
+}
+
+$csrf = $_GET['csrf'] ?? '';
+if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
+    http_response_code(419);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Ungültiges CSRF-Token.';
+    exit;
+}
+
+$email = strtolower(trim((string) ($_GET['email'] ?? '')));
+if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
+    http_response_code(422);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Bitte gültige E-Mail eingeben.';
+    exit;
+}
+
+$field = trim((string) ($_GET['field'] ?? ''));
+$index = (int) ($_GET['index'] ?? -1);
+if ($field === '' || $index < 0) {
+    http_response_code(422);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Ungültiger Upload-Eintrag.';
+    exit;
+}
+
+/** @return string|null */
+function resolveStoredPreviewPath(array $entry, array $app): ?string
+{
+    $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
+    if ($baseDir === '') {
+        return null;
+    }
+
+    $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
+    $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
+    if ($storedDir === '' || $storedFilename === '') {
+        return null;
+    }
+    if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
+        return null;
+    }
+    if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
+        return null;
+    }
+    if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
+        return null;
+    }
+
+    $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
+    $realBase = realpath($baseDir);
+    $realPath = realpath($path);
+    if ($realBase !== false && $realPath !== false && !str_starts_with($realPath, $realBase)) {
+        return null;
+    }
+
+    return $path;
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('preview-upload:%s:%s', $ip, $email);
+if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
+    http_response_code(429);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Zu viele Anfragen. Bitte später erneut versuchen.';
+    exit;
+}
+
+$store = new JsonStore();
+$draft = $store->getDraft($email);
+
+if (!is_array($draft)) {
+    http_response_code(404);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Entwurf nicht gefunden.';
+    exit;
+}
+
+$uploads = (array) ($draft['uploads'] ?? []);
+$files = $uploads[$field] ?? null;
+$entry = (is_array($files) && isset($files[$index]) && is_array($files[$index])) ? $files[$index] : null;
+if (!is_array($entry)) {
+    http_response_code(404);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Upload nicht gefunden.';
+    exit;
+}
+
+$path = resolveStoredPreviewPath($entry, $app);
+if ($path === null || !is_file($path)) {
+    http_response_code(404);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo 'Datei nicht gefunden.';
+    exit;
+}
+
+$mime = (string) ($entry['mime'] ?? '');
+if ($mime === '') {
+    $detected = @mime_content_type($path);
+    $mime = is_string($detected) ? $detected : 'application/octet-stream';
+}
+
+$downloadName = (string) ($entry['original_filename'] ?? basename($path));
+$fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName) ?: 'upload.bin';
+$encodedName = rawurlencode($downloadName);
+
+header('Content-Type: ' . $mime);
+header('X-Content-Type-Options: nosniff');
+header('Content-Length: ' . (string) filesize($path));
+header('Content-Disposition: inline; filename="' . $fallbackName . '"; filename*=UTF-8\'\'' . $encodedName);
+readfile($path);
+exit;

+ 46 - 0
assets/css/base.css

@@ -567,12 +567,58 @@ small {
   background: var(--brand-surface);
   color: var(--brand-text);
   font-size: 0.9rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.65rem;
+  flex-wrap: wrap;
 }
 
 .upload-item:first-child {
   margin-top: 0;
 }
 
+.upload-item-info {
+  flex: 1 1 250px;
+  min-width: 180px;
+  overflow-wrap: anywhere;
+}
+
+.upload-item-actions {
+  display: inline-flex;
+  gap: 0.45rem;
+  flex-wrap: wrap;
+}
+
+.upload-item-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0.28rem 0.55rem;
+  border: 1px solid var(--brand-border);
+  border-radius: 6px;
+  background: transparent;
+  color: var(--brand-text);
+  text-decoration: none;
+  font: inherit;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+.upload-item-btn:hover {
+  background: var(--brand-surface-alt);
+}
+
+.upload-item-btn:disabled {
+  opacity: 0.65;
+  cursor: not-allowed;
+}
+
+.upload-item-btn-danger {
+  border-color: var(--brand-danger);
+  color: #ffccd1;
+}
+
 .status-text {
   min-height: 1.2rem;
   margin-top: 1rem;

+ 0 - 0
assets/images/feuerwehr-Logo-invers.webp → assets/images/feuerwehr-logo-invers.webp


+ 111 - 4
assets/js/form.js

@@ -89,6 +89,14 @@
     return (baseUrl ? baseUrl + '/' : '/') + normalizedPath;
   }
 
+  function scrollWizardToTop() {
+    if (!wizardSection || wizardSection.classList.contains('hidden')) {
+      return;
+    }
+
+    wizardSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+  }
+
   function setFeedbackText(target, text, isError) {
     if (!target) {
       return;
@@ -201,6 +209,15 @@
     }
   }
 
+  function setResetActionVisible(isVisible) {
+    if (!resetDataBtn) {
+      return;
+    }
+
+    resetDataBtn.classList.toggle('hidden', !isVisible);
+    resetDataBtn.disabled = !isVisible;
+  }
+
   function enterCompactStatus(email) {
     statusEmailValue.textContent = email;
     startSection.classList.add('compact-mode');
@@ -218,6 +235,7 @@
     startActions.classList.remove('hidden');
     statusEmailValue.textContent = '-';
     setDraftStatus('Noch nicht gespeichert', false);
+    setResetActionVisible(true);
   }
 
   function lockEmail(email) {
@@ -1046,6 +1064,42 @@
     refreshRequiredMarkers();
   }
 
+  function formatUploadTimestamp(isoDate) {
+    const formatted = formatTimestamp(String(isoDate || ''));
+    if (formatted !== '') {
+      return formatted;
+    }
+    return String(isoDate || '');
+  }
+
+  function buildUploadPreviewUrl(fieldKey, index) {
+    const params = new URLSearchParams();
+    params.set('csrf', boot.csrf);
+    params.set('email', state.email);
+    params.set('field', fieldKey);
+    params.set('index', String(index));
+    return appUrl('api/upload-preview.php') + '?' + params.toString();
+  }
+
+  async function deleteUploadedFile(fieldKey, index) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('email', state.email);
+    fd.append('website', '');
+    fd.append('field', fieldKey);
+    fd.append('index', String(index));
+
+    const response = await postForm(appUrl('api/delete-upload.php'), fd);
+    renderUploadInfo(response.uploads || {});
+
+    const ts = formatTimestamp(response.updated_at);
+    setDraftStatus('Gespeichert: ' + (ts || 'gerade eben'), false);
+
+    if (state.currentStep === state.summaryStep) {
+      renderSummary();
+    }
+  }
+
   function renderUploadInfo(uploads) {
     state.uploads = uploads && typeof uploads === 'object' ? uploads : {};
 
@@ -1059,10 +1113,52 @@
         return;
       }
 
-      state.uploads[field].forEach((item) => {
+      state.uploads[field].forEach((item, index) => {
         const div = document.createElement('div');
         div.className = 'upload-item';
-        div.textContent = item.original_filename + ' (' + item.uploaded_at + ')';
+
+        const info = document.createElement('div');
+        info.className = 'upload-item-info';
+        const filename = String((item && item.original_filename) || (item && item.stored_filename) || 'Datei');
+        const uploadedAt = formatUploadTimestamp(item && item.uploaded_at);
+        info.textContent = uploadedAt !== '' ? filename + ' (' + uploadedAt + ')' : filename;
+        div.appendChild(info);
+
+        const actions = document.createElement('div');
+        actions.className = 'upload-item-actions';
+
+        const previewLink = document.createElement('a');
+        previewLink.className = 'upload-item-btn';
+        previewLink.href = buildUploadPreviewUrl(field, index);
+        previewLink.target = '_blank';
+        previewLink.rel = 'noopener noreferrer';
+        previewLink.textContent = 'Vorschau';
+        actions.appendChild(previewLink);
+
+        const deleteBtn = document.createElement('button');
+        deleteBtn.type = 'button';
+        deleteBtn.className = 'upload-item-btn upload-item-btn-danger';
+        deleteBtn.textContent = 'Löschen';
+        deleteBtn.addEventListener('click', async () => {
+          const confirmed = window.confirm('Diesen Upload wirklich löschen?');
+          if (!confirmed) {
+            return;
+          }
+
+          deleteBtn.disabled = true;
+          try {
+            await deleteUploadedFile(field, index);
+            setFeedback('Upload gelöscht.', false);
+          } catch (err) {
+            const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
+            setFeedback(msg, true);
+          } finally {
+            deleteBtn.disabled = false;
+          }
+        });
+        actions.appendChild(deleteBtn);
+
+        div.appendChild(actions);
         target.appendChild(div);
       });
     });
@@ -1283,6 +1379,7 @@
       submitBtn.disabled = true;
       nextBtn.disabled = true;
       prevBtn.disabled = true;
+      setResetActionVisible(false);
       if (submitLabel) {
         submitLabel.textContent = 'Abgesendet';
       }
@@ -1337,11 +1434,17 @@
     try {
       const result = await loadDraft(email);
       lockEmail(email);
+      setResetActionVisible(true);
 
       if (result.already_submitted) {
         wizardSection.classList.add('hidden');
         setDraftStatus('Antrag bereits abgeschlossen', false);
-        setFeedback(boot.contactEmail ? 'Kontakt: ' + boot.contactEmail : '', false);
+        setFeedback(
+          result.message || 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+          false,
+          'start'
+        );
+        setResetActionVisible(false);
         stopAutosave();
         return;
       }
@@ -1350,8 +1453,9 @@
       fillFormData(result.data || {});
       renderUploadInfo(result.uploads || {});
 
-      state.currentStep = Math.min(Math.max(Number(result.step || 1), 1), state.totalSteps);
+      state.currentStep = 1;
       updateProgress();
+      scrollWizardToTop();
       startAutosave();
 
       const loadedAt = formatTimestamp(result.updated_at);
@@ -1440,6 +1544,7 @@
       }
       state.currentStep -= 1;
       updateProgress();
+      scrollWizardToTop();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
       setFeedback(msg, true);
@@ -1455,6 +1560,7 @@
       await saveDraft(false);
       state.currentStep += 1;
       updateProgress();
+      scrollWizardToTop();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
       setFeedback(msg, true);
@@ -1484,6 +1590,7 @@
       if (payload.already_submitted) {
         wizardSection.classList.add('hidden');
         setDraftStatus('Antrag bereits abgeschlossen', false);
+        setResetActionVisible(false);
       }
     }
   });

+ 0 - 7
bin/.htaccess

@@ -1,7 +0,0 @@
-<IfModule mod_authz_core.c>
-  Require all denied
-</IfModule>
-<IfModule !mod_authz_core.c>
-  Order allow,deny
-  Deny from all
-</IfModule>

+ 0 - 7
config/.htaccess

@@ -1,7 +0,0 @@
-<IfModule mod_authz_core.c>
-  Require all denied
-</IfModule>
-<IfModule !mod_authz_core.c>
-  Order allow,deny
-  Deny from all
-</IfModule>

+ 1 - 0
config/app.sample.php

@@ -8,6 +8,7 @@ return [
     'project_name' => 'Feuerwehr Mitgliedsantrag',
     'base_url' => '/',
     'contact_email' => 'kontakt@example.org',
+    'submission_success_message' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
     'disclaimer' => [
         'title' => 'Wichtiger Hinweis',
         'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfaeltig.\n\nMit dem Fortfahren bestaetigen Sie, dass Ihre Angaben vollstaendig und wahrheitsgemaess sind.\nIhre Daten werden ausschliesslich zur Bearbeitung des Mitgliedsantrags verwendet.",

+ 0 - 7
docs/.htaccess

@@ -1,7 +0,0 @@
-<IfModule mod_authz_core.c>
-  Require all denied
-</IfModule>
-<IfModule !mod_authz_core.c>
-  Order allow,deny
-  Deny from all
-</IfModule>

+ 13 - 13
docs/AI_OVERVIEW.md → docs/ai_overview.md

@@ -26,15 +26,15 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
   - `admin/download-zip.php`
   - `admin/delete.php`
 - Kernlogik:
-  - `src/Storage/JsonStore.php`
-  - `src/Storage/FileUploadStore.php`
-  - `src/Form/Validator.php`
-  - `src/Security/Csrf.php`
-  - `src/Security/RateLimiter.php`
-  - `src/Mail/Mailer.php` (HTML-Mails + PDF-Anhänge)
-  - `src/Mail/MimeMailBuilder.php` (MIME-Mails via nativer mail()-Funktion)
-  - `src/Mail/PdfGenerator.php` (FPDF, Antrags- und Anlagen-PDFs)
-  - `src/Mail/SubmissionFormatter.php` (Formulardaten für Mail/PDF aufbereiten)
+  - `src/storage/jsonstore.php`
+  - `src/storage/fileuploadstore.php`
+  - `src/form/validator.php`
+  - `src/security/csrf.php`
+  - `src/security/ratelimiter.php`
+  - `src/mail/mailer.php` (HTML-Mails + PDF-Anhänge)
+  - `src/mail/mimemailbuilder.php` (MIME-Mails via nativer mail()-Funktion)
+  - `src/mail/pdfgenerator.php` (FPDF, Antrags- und Anlagen-PDFs)
+  - `src/mail/submissionformatter.php` (Formulardaten für Mail/PDF aufbereiten)
 - Bibliotheken:
   - `lib/fpdf/` — FPDF 1.86, einzelne Klasse + Schrift-Metriken
 
@@ -58,10 +58,10 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 - Neue Formularfelder: `config/form_schema.php`
 - Pflichtregeln ändern: `config/form_schema.php` (`required` / `required_if`)
 - Upload-Typen/Limits: `config/app.local.php` + optional pro Feld im Schema
-- Admin-Session/Login: `config/app.local.php` + `src/Admin/Auth.php`
-- Mailtexte/Empfänger: `config/mail.local.php` + `src/Mail/Mailer.php`
-- Retention-Tage: `config/app.local.php` + Cron `bin/cleanup.php`
-- Rate-Limit-Parameter: `config/app.local.php -> rate_limit` (Details: `docs/RATE_LIMITING.md`)
+- Admin-Session/Login: `config/app.local.php` + `admin/auth.php`
+- Mailtexte/Empfänger: `config/mail.local.php` + `src/mail/mailer.php`
+- Retention-Tage: `config/app.local.php` + Cron `admin/cleanup.php`
+- Rate-Limit-Parameter: `config/app.local.php -> rate_limit` (Details: `docs/rate_limiting.md`)
 - Disclaimer-Startseite: `config/app.local.php -> disclaimer` + `index.php`
 - Versionskontrollierte Config-Vorlagen: `config/app.sample.php`, `config/mail.sample.php`
 - Lokale Runtime-Configs (nicht versioniert): `config/app.local.php`, `config/mail.local.php`

+ 0 - 0
docs/AUTH_INTEGRATION.md → docs/auth_integration.md


+ 0 - 0
docs/FORM_SCHEMA.md → docs/form_schema.md


+ 99 - 0
docs/initial_setup.md

@@ -0,0 +1,99 @@
+# Initial Setup
+
+## Voraussetzungen
+
+- Apache HTTPD mit aktivem `mod_rewrite`
+- `AllowOverride All` für den Projektpfad
+- PHP `>= 8.0`
+- Schreibrechte für `storage/` (Webserver-User)
+
+## Erforderliche PHP-Erweiterungen
+
+- `fileinfo` (MIME-Prüfung bei Uploads)
+- `iconv` (Zeichensatzkonvertierung für PDF-Ausgabe)
+- `zip` (Admin-Funktion: ZIP-Download aller Uploads)
+
+Hinweis:
+- `json`, `session`, `filter`, `hash`, `openssl` sind in Standard-PHP-Builds typischerweise vorhanden und werden verwendet.
+- `gd` wird empfohlen: WebP-Bilder können sonst nicht in PDF-Dateien eingebettet werden.
+
+Schnellcheck:
+
+```bash
+php -m | grep -Ei 'fileinfo|iconv|zip|gd'
+php -r "echo class_exists('ZipArchive') ? 'ZipArchive ok' : 'ZipArchive fehlt', PHP_EOL;"
+```
+
+## 1) Dateien bereitstellen
+
+```bash
+cp config/app.sample.php config/app.local.php
+cp config/mail.sample.php config/mail.local.php
+```
+
+## 2) Pflichtwerte konfigurieren
+
+### `config/app.local.php`
+
+Diese Werte müssen gesetzt/validiert werden:
+
+- `base_url` (z. B. `'/antrag'` bei Subfolder-Hosting, sonst `'/'`)
+- `contact_email`
+- `admin.credentials`:
+  - `username`
+  - `password_hash`
+- optional, aber meist anzupassen:
+  - `project_name`
+  - `submission_success_message`
+  - `disclaimer.*`
+  - `retention.*`
+  - `rate_limit.*`
+  - `uploads.*`
+  - `storage.*` (falls Speicherorte abweichend sein sollen)
+
+Passwort-Hash erzeugen:
+
+```bash
+php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
+```
+
+### `config/mail.local.php`
+
+Diese Werte müssen gesetzt werden:
+
+- `from`
+- `from_name`
+- `recipients` (mindestens ein Admin-Empfänger)
+- `subjects.admin`
+- `subjects.applicant`
+
+Zusätzlich muss der Mailversand auf dem Host funktionieren (`mail()`/MTA-Konfiguration).
+
+## 3) Verzeichnisrechte
+
+Mindestens diese Verzeichnisse müssen für PHP schreibbar sein:
+
+- `storage/drafts`
+- `storage/submissions`
+- `storage/uploads`
+- `storage/rate_limit`
+- `storage/logs`
+- `storage/locks`
+
+## 4) Cronjob einrichten
+
+Täglich ausführen:
+
+```bash
+php /pfad/zum/projekt/admin/cleanup.php
+```
+
+## 5) .htaccess-Setup
+
+Aktuell wird ein zentrales Root-`.htaccess` verwendet.
+
+- Es blockiert direkte Zugriffe auf interne Ordner (`config`, `src`, `storage`, `docs`, `lib`).
+- Es blockiert interne Admin-Skripte (`admin/auth.php`, `admin/cleanup.php`, `admin/test-mail.php`).
+- Zusätzliche `.htaccess` pro Unterordner sind dafür nicht erforderlich.
+
+Wichtig: Wenn `AllowOverride` deaktiviert ist, greifen diese Regeln nicht.

+ 3 - 3
docs/OPERATIONS.md → docs/operations.md

@@ -12,7 +12,7 @@
 Täglich ausführen:
 
 ```bash
-php /pfad/zum/projekt/bin/cleanup.php
+php /pfad/zum/projekt/admin/cleanup.php
 ```
 
 ## Retention
@@ -42,7 +42,7 @@ php /pfad/zum/projekt/bin/cleanup.php
 
 - Konfiguration: `config/app.local.php -> rate_limit`
 - Persistenz: `storage/rate_limit/`
-- Detaillierte Doku: `docs/RATE_LIMITING.md`
+- Detaillierte Doku: `docs/rate_limiting.md`
 - Bei erhöhten `429`-Antworten zuerst `requests/window_seconds` prüfen und gegen reale Nutzerlast kalibrieren.
 - Für Tests kann das Limiting global deaktiviert werden: `rate_limit.enabled = false`.
 
@@ -69,5 +69,5 @@ Regelmäßig sichern:
 - Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
 - Login geht nicht: `config/app.local.php -> admin.credentials` prüfen (username + password_hash).
 - ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.
-- Viele `429` Antworten: `docs/RATE_LIMITING.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
+- Viele `429` Antworten: `docs/rate_limiting.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
 - 500 ohne Apache/PHP-Fehlerausgabe: `storage/logs/php_fatal.log` und `storage/logs/php_runtime.log` prüfen.

+ 1 - 1
docs/RATE_LIMITING.md → docs/rate_limiting.md

@@ -6,7 +6,7 @@ Schützt die API gegen Spam, Bot-Traffic und Missbrauch durch zu viele Anfragen
 
 ## Implementierung
 
-- Klasse: `src/Security/RateLimiter.php`
+- Klasse: `src/security/ratelimiter.php`
 - Strategie: Sliding-Window auf Basis von Zeitstempeln
 - Persistenz: Flat Files in `storage/rate_limit/`
 - Schlüsselablage: pro Key wird `sha256(key).json` verwendet

+ 1 - 1
docs/STYLE_SYSTEM.md → docs/style_system.md

@@ -314,7 +314,7 @@ Email styles are inline HTML/CSS and must keep the same dark-theme palette.
 
 ## Asset and Branding Contract
 - Required organization logo:
-  - `assets/feuerwehr-Logo-invers.webp`
+  - `assets/images/feuerwehr-logo-invers.webp`
   - If rebranding for another intranet, replace asset file while preserving placement/sizing behavior.
 - Media/image behavior:
   - source path pattern in current system: `assets/images/<filename>`

+ 1 - 1
index.php

@@ -204,7 +204,7 @@ function renderField(array $field, string $addressDisclaimerText): void
 <header class="site-header">
     <div class="container header-inner">
         <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('index.php')) ?>">
-            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-Logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
             <div>
                 <div class="brand-title"><?= htmlspecialchars((string) $app['project_name']) ?></div>
                 <div class="brand-subtitle">Feuerwehr Freising</div>

+ 11 - 8
README.md → readme.md

@@ -30,12 +30,14 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
 - `src/` PHP-Logik
 - `config/` Konfiguration
 - `storage/` Datenablage (JSON, Uploads, Logs)
-- `bin/cleanup.php` tägliche Bereinigung
+- `admin/cleanup.php` tägliche Bereinigung
 - `docs/` AI-first Dokumentation
 - `.htaccess` Apache-Schutz und Routing
 
 ## Setup (Shared Hosting)
 
+Detaillierte Schritt-für-Schritt-Anleitung: `docs/initial_setup.md`
+
 1. Projekt hochladen.
 2. Apache verwenden (mit aktiviertem `mod_rewrite`) und `AllowOverride All` für das Projekt sicherstellen.
 3. Document Root auf das Projekt-Root setzen.
@@ -51,7 +53,7 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
    - Ergebnis in `config/app.local.php -> admin.credentials[*].password_hash`
    - Benutzername in `config/app.local.php -> admin.credentials[*].username`
 8. Cronjob einrichten (täglich):
-   - `php /pfad/zum/projekt/bin/cleanup.php`
+   - `php /pfad/zum/projekt/admin/cleanup.php`
 
 Hinweis:
 - `config/app.php` und `config/mail.php` sind stabile Loader-Dateien im Repo.
@@ -64,7 +66,7 @@ Hinweis:
 - Honeypot + Rate Limit aktiv.
 - Rate Limit fuer Tests deaktivierbar ueber `config/app.local.php -> rate_limit.enabled = false`.
 - Uploads werden auf Typ, MIME und Größe geprüft.
-- Interne Ordner (`config`, `src`, `storage`, `bin`, `docs`) werden per `.htaccess` blockiert.
+- Interne Ordner (`config`, `src`, `storage`, `docs`, `lib`) werden per `.htaccess` blockiert.
 
 ## Wichtige URLs
 
@@ -78,8 +80,9 @@ Lokale PHP-Laufzeit wird benötigt (CLI + Webserver), um Syntaxchecks/Tests ausz
 
 ## Weiterführende Doku
 
-- `docs/AI_OVERVIEW.md`
-- `docs/FORM_SCHEMA.md`
-- `docs/OPERATIONS.md`
-- `docs/RATE_LIMITING.md`
-- `docs/AUTH_INTEGRATION.md`
+- `docs/ai_overview.md`
+- `docs/initial_setup.md`
+- `docs/form_schema.md`
+- `docs/operations.md`
+- `docs/rate_limiting.md`
+- `docs/auth_integration.md`

+ 0 - 93
scripts/wsl-dev-server-setup.sh

@@ -1,93 +0,0 @@
-#!/usr/bin/env bash
-# WSL dev server setup: remove Caddy, install Apache+PHP, configure for feuerwehr project.
-# Run with: bash scripts/wsl-dev-server-setup.sh
-# You will be prompted for sudo when needed.
-
-set -e
-
-PROJECT_NAME="feuerwehr-freising-antragsformular"
-REPO_ROOT="/home/user/${PROJECT_NAME}"
-WWW_ROOT="/var/www"
-VHOST_NAME="feuerwehr-dev"
-SERVER_NAME="feuerwehr.local"
-
-echo "=== 1. Remove Caddy ==="
-if systemctl is-active --quiet caddy 2>/dev/null; then
-  sudo systemctl stop caddy
-  echo "Stopped caddy."
-fi
-if systemctl is-enabled --quiet caddy 2>/dev/null; then
-  sudo systemctl disable caddy
-  echo "Disabled caddy."
-fi
-if command -v caddy &>/dev/null; then
-  sudo apt-get remove -y caddy || true
-  echo "Removed caddy package."
-fi
-if [ -d /etc/caddy ]; then
-  sudo rm -rf /etc/caddy
-  echo "Removed /etc/caddy."
-fi
-if [ -d /var/lib/caddy ]; then
-  sudo rm -rf /var/lib/caddy
-  echo "Removed /var/lib/caddy."
-fi
-echo "Caddy removal done."
-
-echo ""
-echo "=== 2. Install Apache and PHP ==="
-sudo apt-get update -qq
-sudo apt-get install -y apache2 libapache2-mod-php php php-cli php-json php-mbstring php-xml
-sudo a2enmod rewrite
-echo "Apache and PHP installed; mod_rewrite enabled."
-
-echo ""
-echo "=== 3. Document root and symlink ==="
-sudo mkdir -p "$WWW_ROOT"
-if [ ! -L "${WWW_ROOT}/${PROJECT_NAME}" ]; then
-  sudo ln -s "$REPO_ROOT" "${WWW_ROOT}/${PROJECT_NAME}"
-  echo "Symlink ${WWW_ROOT}/${PROJECT_NAME} -> ${REPO_ROOT} created."
-else
-  echo "Symlink ${WWW_ROOT}/${PROJECT_NAME} already exists."
-fi
-# Ensure www-data can read project and write storage
-sudo chmod -R o+rX "$REPO_ROOT"
-sudo chmod -R o+w "$REPO_ROOT/storage" 2>/dev/null || true
-echo "Permissions set for www-data."
-
-echo ""
-echo "=== 4. Apache vhost ==="
-VHOST_FILE="/etc/apache2/sites-available/${VHOST_NAME}.conf"
-sudo tee "$VHOST_FILE" << EOF
-<VirtualHost *:80>
-    ServerName ${SERVER_NAME}
-    DocumentRoot ${WWW_ROOT}/${PROJECT_NAME}
-    <Directory ${WWW_ROOT}/${PROJECT_NAME}>
-        AllowOverride All
-        Require all granted
-    </Directory>
-</VirtualHost>
-EOF
-echo "Vhost written to $VHOST_FILE."
-sudo a2ensite "${VHOST_NAME}.conf"
-# Disable default site if it would take port 80
-sudo a2dissite 000-default.conf 2>/dev/null || true
-sudo systemctl reload apache2
-echo "Site enabled and Apache reloaded."
-
-echo ""
-echo "=== Hosts entry ==="
-echo "Add this line to your hosts file so ${SERVER_NAME} resolves:"
-echo "  127.0.0.1 ${SERVER_NAME}"
-echo ""
-echo "  Windows (run as Admin): notepad C:\\Windows\\System32\\drivers\\etc\\hosts"
-echo "  WSL: sudo sed -i '/${SERVER_NAME}/d' /etc/hosts; echo '127.0.0.1 ${SERVER_NAME}' | sudo tee -a /etc/hosts"
-if ! grep -q "${SERVER_NAME}" /etc/hosts 2>/dev/null; then
-  echo "127.0.0.1 ${SERVER_NAME}" | sudo tee -a /etc/hosts
-  echo "Added ${SERVER_NAME} to /etc/hosts (WSL)."
-fi
-
-echo ""
-echo "=== Done ==="
-echo "Open http://${SERVER_NAME} in your browser to view the app."
-echo "To add more projects: symlink under ${WWW_ROOT}/ and add a new site in /etc/apache2/sites-available/."

+ 0 - 7
src/.htaccess

@@ -1,7 +0,0 @@
-<IfModule mod_authz_core.c>
-  Require all denied
-</IfModule>
-<IfModule !mod_authz_core.c>
-  Order allow,deny
-  Deny from all
-</IfModule>

+ 0 - 0
src/App/Bootstrap.php → src/app/bootstrap.php


+ 9 - 1
src/autoload.php

@@ -5,6 +5,14 @@ declare(strict_types=1);
 require_once dirname(__DIR__) . '/lib/fpdf/fpdf.php';
 
 spl_autoload_register(static function (string $class): void {
+    if ($class === 'App\\Admin\\Auth') {
+        $adminAuthFile = dirname(__DIR__) . '/admin/auth.php';
+        if (is_file($adminAuthFile)) {
+            require $adminAuthFile;
+        }
+        return;
+    }
+
     $prefix = 'App\\';
     $baseDir = __DIR__ . '/';
 
@@ -13,7 +21,7 @@ spl_autoload_register(static function (string $class): void {
     }
 
     $relativeClass = substr($class, strlen($prefix));
-    $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
+    $file = $baseDir . strtolower(str_replace('\\', '/', $relativeClass)) . '.php';
 
     if (is_file($file)) {
         require $file;

+ 0 - 0
src/Form/FormSchema.php → src/form/formschema.php


+ 0 - 0
src/Form/Validator.php → src/form/validator.php


+ 0 - 0
src/Mail/Mailer.php → src/mail/mailer.php


+ 24 - 6
src/Mail/MimeMailBuilder.php → src/mail/mimemailbuilder.php

@@ -22,32 +22,32 @@ final class MimeMailBuilder
 
     public function setFrom(string $address, string $name = ''): self
     {
-        $this->from = $address;
-        $this->fromName = $name;
+        $this->from = $this->sanitizeAddress($address);
+        $this->fromName = $this->sanitizeHeaderText($name);
         return $this;
     }
 
     public function setTo(string $address): self
     {
-        $this->to = $address;
+        $this->to = $this->sanitizeAddress($address);
         return $this;
     }
 
     public function setSubject(string $subject): self
     {
-        $this->subject = $subject;
+        $this->subject = $this->sanitizeHeaderText($subject);
         return $this;
     }
 
     public function setHtmlBody(string $html): self
     {
-        $this->htmlBody = $html;
+        $this->htmlBody = $this->sanitizeBodyText($html);
         return $this;
     }
 
     public function setTextBody(string $text): self
     {
-        $this->textBody = $text;
+        $this->textBody = $this->sanitizeBodyText($text);
         return $this;
     }
 
@@ -173,6 +173,24 @@ final class MimeMailBuilder
 
     private function sanitizeFilename(string $name): string
     {
+        $name = $this->sanitizeHeaderText($name);
         return preg_replace('/[^\w.\-äöüÄÖÜß]+/u', '_', $name) ?: 'attachment';
     }
+
+    private function sanitizeAddress(string $value): string
+    {
+        $value = preg_replace('/[\x00-\x1F\x7F]+/', '', $value) ?? '';
+        return trim($value);
+    }
+
+    private function sanitizeHeaderText(string $value): string
+    {
+        $value = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $value) ?? '';
+        return trim($value);
+    }
+
+    private function sanitizeBodyText(string $value): string
+    {
+        return preg_replace('/[\x00\x0B\x0C]/', '', $value) ?? '';
+    }
 }

+ 0 - 0
src/Mail/PdfGenerator.php → src/mail/pdfgenerator.php


+ 0 - 0
src/Mail/SubmissionFormatter.php → src/mail/submissionformatter.php


+ 0 - 0
src/Security/Csrf.php → src/security/csrf.php


+ 0 - 0
src/Security/RateLimiter.php → src/security/ratelimiter.php


+ 0 - 0
src/Storage/FileSystem.php → src/storage/filesystem.php


+ 0 - 0
src/Storage/FileUploadStore.php → src/storage/fileuploadstore.php


+ 30 - 0
src/Storage/JsonStore.php → src/storage/jsonstore.php

@@ -77,6 +77,36 @@ final class JsonStore
         return $payload;
     }
 
+    /**
+     * Replaces the full draft payload for an email (no upload merge).
+     *
+     * @param array<string, mixed> $draft
+     * @return array<string, mixed>
+     */
+    public function replaceDraft(string $email, array $draft): array
+    {
+        $now = date('c');
+        $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400));
+
+        $current = $this->getDraft($email) ?? [];
+
+        $payload = [
+            'email' => strtolower(trim($email)),
+            'application_key' => $this->emailKey($email),
+            'status' => 'draft',
+            'created_at' => $current['created_at'] ?? $now,
+            'updated_at' => $now,
+            'expires_at' => $expires,
+            'step' => $draft['step'] ?? ($current['step'] ?? 1),
+            'form_data' => (array) ($draft['form_data'] ?? []),
+            'uploads' => (array) ($draft['uploads'] ?? []),
+        ];
+
+        $this->writeJsonFile($this->draftPath($email), $payload);
+
+        return $payload;
+    }
+
     /**
      * @param array<string, mixed> $submission
      * @return array<string, mixed>

+ 0 - 7
storage/.htaccess

@@ -1,7 +0,0 @@
-<IfModule mod_authz_core.c>
-  Require all denied
-</IfModule>
-<IfModule !mod_authz_core.c>
-  Order allow,deny
-  Deny from all
-</IfModule>

+ 0 - 0
TODO.md → todo.md