Bladeren bron

adding upload preview and delete.
modifying scroll behaviour

Medowar 1 maand geleden
bovenliggende
commit
2f5f7bf82e
7 gewijzigde bestanden met toevoegingen van 495 en 5 verwijderingen
  1. 173 0
      api/delete-upload.php
  2. 20 1
      api/submit.php
  3. 132 0
      api/upload-preview.php
  4. 46 0
      assets/css/base.css
  5. 93 4
      assets/js/form.js
  6. 1 0
      config/app.sample.php
  7. 30 0
      src/Storage/JsonStore.php

+ 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,
+]);

+ 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;

+ 93 - 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;
@@ -1046,6 +1054,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 +1103,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);
       });
     });
@@ -1277,7 +1363,7 @@
       const response = await postForm(appUrl('api/submit.php'), payload);
       clearErrors();
       setDraftStatus('Abgeschlossen', false);
-      setFeedback('Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
+      setFeedback(response.message || 'Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
       setSubmitting(false);
 
       submitBtn.disabled = true;
@@ -1350,8 +1436,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 +1527,7 @@
       }
       state.currentStep -= 1;
       updateProgress();
+      scrollWizardToTop();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
       setFeedback(msg, true);
@@ -1455,6 +1543,7 @@
       await saveDraft(false);
       state.currentStep += 1;
       updateProgress();
+      scrollWizardToTop();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
       setFeedback(msg, true);

+ 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.",

+ 30 - 0
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>