Ver Fonte

initial

Josef Straßl há 1 mês atrás
commit
4b7c21d08e

+ 26 - 0
.htaccess

@@ -0,0 +1,26 @@
+Options -Indexes
+
+<IfModule mod_rewrite.c>
+RewriteEngine On
+
+# Block hidden files/folders (e.g. .env, .git)
+RewriteRule "(^|/)\." - [F,L]
+
+# Block internal directories from public access
+RewriteRule "^(config|src|storage|bin|docs)(/|$)" - [F,L,NC]
+
+# Keep direct file-based endpoints, fallback unknown routes to index
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^ index.php [L]
+</IfModule>
+
+<FilesMatch "(^\.|\.(md|log)$)">
+  <IfModule mod_authz_core.c>
+    Require all denied
+  </IfModule>
+  <IfModule !mod_authz_core.c>
+    Order allow,deny
+    Deny from all
+  </IfModule>
+</FilesMatch>

+ 57 - 0
README.md

@@ -0,0 +1,57 @@
+# Feuerwehr digitaler Antrag (MVP)
+
+Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches Frontend).
+
+## Features
+
+- Mehrstufiges Wizard-Formular
+- Autosave + Wiederaufnahme über E-Mail
+- Genau ein Antrag pro E-Mail
+- Uploads mit Original-Dateiname in kurzem Zufallsordner
+- Abschlussversand per E-Mail (intern + Antragsteller)
+- Admin-Bereich mit Login, Detailansicht, Download einzeln/ZIP, Löschen
+- Cleanup per Cron (Entwürfe 14 Tage, Submissions 90 Tage)
+
+## Projektstruktur
+
+- `index.php` im Projekt-Root als Einstiegspunkt
+- `api/`, `admin/`, `assets/` direkt im Projekt-Root
+- `src/` PHP-Logik
+- `config/` Konfiguration
+- `storage/` Datenablage (JSON, Uploads, Logs)
+- `bin/cleanup.php` tägliche Bereinigung
+- `docs/` AI-first Dokumentation
+- `.htaccess` Apache-Schutz und Routing
+
+## Setup (Shared Hosting)
+
+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.
+4. Schreibrechte für `storage/` sicherstellen (mind. Webserver-User).
+5. Konfiguration anpassen:
+   - `config/app.php` (Admin-Passwort, Kontakt, Retention)
+   - `config/mail.php` (Absender, Empfänger)
+6. Admin-Hash setzen:
+   - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`
+   - Ergebnis in `config/app.php -> admin.password_hash`
+   - Danach `password_plain_fallback` entfernen/leer setzen.
+7. Cronjob einrichten (täglich):
+   - `php /pfad/zum/projekt/bin/cleanup.php`
+
+## Sicherheitshinweise
+
+- CSRF aktiv auf POST-Endpunkten.
+- Honeypot + Rate Limit aktiv.
+- Uploads werden auf Typ, MIME und Größe geprüft.
+- Interne Ordner (`config`, `src`, `storage`, `bin`, `docs`) werden per `.htaccess` blockiert.
+
+## Wichtige URLs
+
+- Formular: `/`
+- Admin Login: `/admin/login.php`
+- Admin Übersicht: `/admin/index.php`
+
+## Entwicklung
+
+Lokale PHP-Laufzeit wird benötigt (CLI + Webserver), um Syntaxchecks/Tests auszuführen.

+ 83 - 0
admin/application.php

@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Security\Csrf;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+$id = trim((string) ($_GET['id'] ?? ''));
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($id);
+
+if ($submission === null) {
+    http_response_code(404);
+    echo 'Antrag nicht gefunden.';
+    exit;
+}
+
+$csrf = Csrf::token();
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Antragsdetails</title>
+    <link rel="stylesheet" href="/assets/css/tokens.css">
+    <link rel="stylesheet" href="/assets/css/base.css">
+</head>
+<body>
+<main class="container">
+    <section class="card">
+        <p><a href="/admin/index.php">Zur Übersicht</a></p>
+        <h1>Antragsdetails</h1>
+        <p><strong>E-Mail:</strong> <?= htmlspecialchars((string) ($submission['email'] ?? '')) ?></p>
+        <p><strong>Eingereicht:</strong> <?= htmlspecialchars((string) ($submission['submitted_at'] ?? '')) ?></p>
+
+        <h2>Formulardaten</h2>
+        <table>
+            <tbody>
+                <?php foreach ((array) ($submission['form_data'] ?? []) as $key => $value): ?>
+                    <tr>
+                        <th><?= htmlspecialchars((string) $key) ?></th>
+                        <td><?= htmlspecialchars(is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?></td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+
+        <h2>Uploads</h2>
+        <?php if (empty($submission['uploads'])): ?>
+            <p>Keine Uploads vorhanden.</p>
+        <?php else: ?>
+            <p><a href="/admin/download-zip.php?id=<?= urlencode((string) ($submission['application_key'] ?? '')) ?>">Alle Uploads als ZIP herunterladen</a></p>
+            <?php foreach ((array) $submission['uploads'] as $field => $files): ?>
+                <h3><?= htmlspecialchars((string) $field) ?></h3>
+                <ul>
+                    <?php foreach ((array) $files as $idx => $file): ?>
+                        <li>
+                            <?= htmlspecialchars((string) ($file['original_filename'] ?? 'Datei')) ?>
+                            - <a href="/admin/download.php?id=<?= urlencode((string) ($submission['application_key'] ?? '')) ?>&field=<?= urlencode((string) $field) ?>&index=<?= urlencode((string) $idx) ?>">Download</a>
+                        </li>
+                    <?php endforeach; ?>
+                </ul>
+            <?php endforeach; ?>
+        <?php endif; ?>
+
+        <h2>Löschen</h2>
+        <form method="post" action="/admin/delete.php" onsubmit="return confirm('Antrag wirklich löschen?');">
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
+            <button type="submit">Antrag löschen</button>
+        </form>
+    </section>
+</main>
+</body>
+</html>

+ 50 - 0
admin/delete.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Security\Csrf;
+use App\Storage\FileSystem;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    http_response_code(405);
+    echo 'Method not allowed';
+    exit;
+}
+
+if (!Csrf::validate((string) ($_POST['csrf'] ?? ''))) {
+    http_response_code(419);
+    echo 'Ungültiges CSRF-Token.';
+    exit;
+}
+
+$id = trim((string) ($_POST['id'] ?? ''));
+if ($id === '') {
+    http_response_code(422);
+    echo 'Ungültige ID.';
+    exit;
+}
+
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($id);
+if ($submission === null) {
+    http_response_code(404);
+    echo 'Antrag nicht gefunden.';
+    exit;
+}
+
+$store->deleteSubmissionByKey($id);
+$app = Bootstrap::config('app');
+$uploadDir = rtrim((string) $app['storage']['uploads'], '/') . '/' . (string) ($submission['application_key'] ?? '');
+FileSystem::removeTree($uploadDir);
+
+header('Location: /admin/index.php');
+exit;

+ 74 - 0
admin/download-zip.php

@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+if (!class_exists('ZipArchive')) {
+    http_response_code(500);
+    echo 'ZipArchive ist auf diesem Server nicht verfügbar.';
+    exit;
+}
+
+$id = trim((string) ($_GET['id'] ?? ''));
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($id);
+if ($submission === null) {
+    http_response_code(404);
+    echo 'Antrag nicht gefunden.';
+    exit;
+}
+
+$app = Bootstrap::config('app');
+$baseUploads = rtrim((string) $app['storage']['uploads'], '/');
+$zipPath = sys_get_temp_dir() . '/antrag_' . $id . '_' . bin2hex(random_bytes(4)) . '.zip';
+
+$zip = new ZipArchive();
+if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+    http_response_code(500);
+    echo 'ZIP konnte nicht erstellt werden.';
+    exit;
+}
+
+foreach ((array) ($submission['uploads'] ?? []) as $field => $files) {
+    foreach ((array) $files as $file) {
+        if (!is_array($file)) {
+            continue;
+        }
+
+        $relativePath = str_replace(['..', '\\'], '', (string) ($file['relative_path'] ?? ''));
+        $fullPath = $baseUploads . '/' . ltrim($relativePath, '/');
+        if (!is_file($fullPath)) {
+            continue;
+        }
+
+        $name = (string) ($file['original_filename'] ?? basename($fullPath));
+        $name = str_replace(["\r", "\n"], '', $name);
+        $zipEntry = $field . '/' . $name;
+
+        $suffix = 1;
+        while ($zip->locateName($zipEntry) !== false) {
+            $zipEntry = $field . '/' . $suffix . '_' . $name;
+            $suffix++;
+        }
+
+        $zip->addFile($fullPath, $zipEntry);
+    }
+}
+
+$zip->close();
+
+header('Content-Type: application/zip');
+header('Content-Length: ' . (string) filesize($zipPath));
+header('Content-Disposition: attachment; filename="antrag_' . $id . '.zip"');
+readfile($zipPath);
+unlink($zipPath);
+exit;

+ 55 - 0
admin/download.php

@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+$id = trim((string) ($_GET['id'] ?? ''));
+$field = trim((string) ($_GET['field'] ?? ''));
+$index = (int) ($_GET['index'] ?? -1);
+
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($id);
+if ($submission === null) {
+    http_response_code(404);
+    echo 'Antrag nicht gefunden.';
+    exit;
+}
+
+$entry = $submission['uploads'][$field][$index] ?? null;
+if (!is_array($entry)) {
+    http_response_code(404);
+    echo 'Datei nicht gefunden.';
+    exit;
+}
+
+$app = Bootstrap::config('app');
+$base = rtrim((string) $app['storage']['uploads'], '/');
+$relativePath = (string) ($entry['relative_path'] ?? '');
+$relativePath = str_replace(['..', '\\'], '', $relativePath);
+$fullPath = $base . '/' . ltrim($relativePath, '/');
+
+if (!is_file($fullPath)) {
+    http_response_code(404);
+    echo 'Datei nicht vorhanden.';
+    exit;
+}
+
+$downloadName = (string) ($entry['original_filename'] ?? basename($fullPath));
+$downloadName = str_replace(["\r", "\n"], '', $downloadName);
+$fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName) ?: 'download.bin';
+$encodedName = rawurlencode($downloadName);
+
+header('Content-Type: ' . ((string) ($entry['mime'] ?? 'application/octet-stream')));
+header('Content-Length: ' . (string) filesize($fullPath));
+header('Content-Disposition: attachment; filename="' . $fallbackName . '"; filename*=UTF-8\'\'' . $encodedName);
+readfile($fullPath);
+exit;

+ 69 - 0
admin/index.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+$store = new JsonStore();
+$list = $store->listSubmissions();
+
+$query = trim((string) ($_GET['q'] ?? ''));
+if ($query !== '') {
+    $list = array_values(array_filter($list, static function (array $item) use ($query): bool {
+        return strpos(strtolower((string) ($item['email'] ?? '')), strtolower($query)) !== false;
+    }));
+}
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Admin Übersicht</title>
+    <link rel="stylesheet" href="/assets/css/tokens.css">
+    <link rel="stylesheet" href="/assets/css/base.css">
+</head>
+<body>
+<main class="container">
+    <section class="card">
+        <h1>Abgeschlossene Anträge</h1>
+        <p><a href="/admin/login.php?logout=1">Abmelden</a></p>
+
+        <form method="get" class="field">
+            <label for="q">Suche E-Mail</label>
+            <input id="q" name="q" value="<?= htmlspecialchars($query) ?>">
+        </form>
+
+        <?php if (empty($list)): ?>
+            <p>Keine Anträge vorhanden.</p>
+        <?php else: ?>
+            <table>
+                <thead>
+                    <tr>
+                        <th>E-Mail</th>
+                        <th>Eingereicht</th>
+                        <th>Aktion</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($list as $item): ?>
+                        <tr>
+                            <td><?= htmlspecialchars((string) ($item['email'] ?? '')) ?></td>
+                            <td><?= htmlspecialchars((string) ($item['submitted_at'] ?? '')) ?></td>
+                            <td><a href="/admin/application.php?id=<?= urlencode((string) ($item['application_key'] ?? '')) ?>">Details</a></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+    </section>
+</main>
+</body>
+</html>

+ 67 - 0
admin/login.php

@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Admin\Auth;
+use App\Security\Csrf;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+
+if (isset($_GET['logout']) && $_GET['logout'] === '1') {
+    $auth->logout();
+    header('Location: /admin/login.php');
+    exit;
+}
+
+if ($auth->isLoggedIn()) {
+    header('Location: /admin/index.php');
+    exit;
+}
+
+$error = '';
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (!Csrf::validate((string) ($_POST['csrf'] ?? ''))) {
+        $error = 'Ungültiges CSRF-Token.';
+    } else {
+        $password = (string) ($_POST['password'] ?? '');
+        if ($auth->login($password)) {
+            header('Location: /admin/index.php');
+            exit;
+        }
+        $error = 'Login fehlgeschlagen.';
+    }
+}
+
+$csrf = Csrf::token();
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Admin Login</title>
+    <link rel="stylesheet" href="/assets/css/tokens.css">
+    <link rel="stylesheet" href="/assets/css/base.css">
+</head>
+<body>
+<main class="container">
+    <section class="card">
+        <h1>Admin Login</h1>
+        <?php if ($error !== ''): ?>
+            <p class="error"><?= htmlspecialchars($error) ?></p>
+        <?php endif; ?>
+        <form method="post">
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="field">
+                <label for="password">Passwort</label>
+                <input id="password" name="password" type="password" required>
+            </div>
+            <button type="submit">Anmelden</button>
+        </form>
+    </section>
+</main>
+</body>
+</html>

+ 59 - 0
api/load-draft.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Storage\JsonStore;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+
+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);
+}
+
+$honeypot = trim((string) ($_POST['website'] ?? ''));
+if ($honeypot !== '') {
+    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);
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('load:%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 Anfragen. Bitte später erneut versuchen.'], 429);
+}
+
+$store = new JsonStore();
+$submission = $store->getSubmissionByEmail($email);
+if ($submission !== null) {
+    Bootstrap::jsonResponse([
+        'ok' => true,
+        'already_submitted' => true,
+        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+    ]);
+}
+
+$draft = $store->getDraft($email);
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'already_submitted' => false,
+    'data' => $draft['form_data'] ?? [],
+    'uploads' => $draft['uploads'] ?? [],
+    'step' => $draft['step'] ?? 1,
+    'updated_at' => $draft['updated_at'] ?? null,
+]);

+ 116 - 0
api/save-draft.php

@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Form\FormSchema;
+use App\Storage\FileUploadStore;
+use App\Storage\JsonStore;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+
+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);
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('save:%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 Speicheranfragen.'], 429);
+}
+
+$step = (int) ($_POST['step'] ?? 1);
+$formDataRaw = $_POST['form_data'] ?? [];
+$formData = [];
+if (is_array($formDataRaw)) {
+    foreach ($formDataRaw as $key => $value) {
+        if (!is_string($key)) {
+            continue;
+        }
+        $formData[$key] = is_array($value) ? '' : trim((string) $value);
+    }
+}
+
+$store = new JsonStore();
+
+try {
+    $result = $store->withEmailLock($email, static function () use ($store, $email, $step, $formData): array {
+        if ($store->hasSubmission($email)) {
+            return [
+                'blocked' => true,
+                'message' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
+            ];
+        }
+
+        return [
+            'blocked' => false,
+            'draft' => $store->saveDraft($email, [
+                'step' => max(1, $step),
+                'form_data' => $formData,
+                'uploads' => [],
+            ]),
+        ];
+    });
+} catch (Throwable $e) {
+    Bootstrap::log('app', 'save-draft lock error: ' . $e->getMessage());
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Speichern derzeit nicht möglich.'], 500);
+}
+
+if (($result['blocked'] ?? false) === true) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'already_submitted' => true,
+        'message' => (string) ($result['message'] ?? 'Bereits abgeschlossen.'),
+    ], 409);
+}
+
+$schema = new FormSchema();
+$uploadStore = new FileUploadStore();
+$uploadResult = $uploadStore->processUploads($_FILES, $schema->getUploadFields(), $store->emailKey($email));
+
+if (!empty($uploadResult['uploads'])) {
+    try {
+        $store->withEmailLock($email, static function () use ($store, $email, $step, $formData, $uploadResult): void {
+            if ($store->hasSubmission($email)) {
+                return;
+            }
+
+            $store->saveDraft($email, [
+                'step' => max(1, $step),
+                'form_data' => $formData,
+                'uploads' => $uploadResult['uploads'],
+            ]);
+        });
+    } catch (Throwable $e) {
+        Bootstrap::log('app', 'save-draft upload merge error: ' . $e->getMessage());
+    }
+}
+
+$draft = $store->getDraft($email);
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Entwurf gespeichert.',
+    'updated_at' => $draft['updated_at'] ?? null,
+    'upload_errors' => $uploadResult['errors'],
+    'uploads' => $draft['uploads'] ?? [],
+]);

+ 146 - 0
api/submit.php

@@ -0,0 +1,146 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Form\FormSchema;
+use App\Form\Validator;
+use App\Mail\Mailer;
+use App\Storage\FileUploadStore;
+use App\Storage\JsonStore;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+
+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);
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('submit:%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 Anfragen.'], 429);
+}
+
+$formDataRaw = $_POST['form_data'] ?? [];
+$formData = [];
+if (is_array($formDataRaw)) {
+    foreach ($formDataRaw as $key => $value) {
+        if (!is_string($key)) {
+            continue;
+        }
+        $formData[$key] = is_array($value) ? '' : trim((string) $value);
+    }
+}
+
+$store = new JsonStore();
+$schema = new FormSchema();
+$uploadStore = new FileUploadStore();
+$validator = new Validator($schema);
+
+// Fast fail before upload processing to avoid writing files for known duplicates.
+if ($store->hasSubmission($email)) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'already_submitted' => true,
+        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+    ], 409);
+}
+
+try {
+    $submitResult = $store->withEmailLock($email, static function () use ($store, $email, $formData, $validator, $uploadStore, $schema): array {
+        if ($store->hasSubmission($email)) {
+            return [
+                'ok' => false,
+                'already_submitted' => true,
+                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            ];
+        }
+
+        $uploadResult = $uploadStore->processUploads($_FILES, $schema->getUploadFields(), $store->emailKey($email));
+        if (!empty($uploadResult['errors'])) {
+            return [
+                'ok' => false,
+                'already_submitted' => false,
+                'message' => 'Fehler bei Uploads.',
+                'errors' => $uploadResult['errors'],
+            ];
+        }
+
+        $draft = $store->getDraft($email) ?? [];
+
+        $mergedFormData = array_merge((array) ($draft['form_data'] ?? []), $formData);
+        $mergedUploads = (array) ($draft['uploads'] ?? []);
+        foreach ($uploadResult['uploads'] as $field => $items) {
+            $mergedUploads[$field] = array_values(array_merge((array) ($mergedUploads[$field] ?? []), $items));
+        }
+
+        $errors = $validator->validateSubmit($mergedFormData, $mergedUploads);
+        if (!empty($errors)) {
+            $store->saveDraft($email, [
+                'step' => 4,
+                'form_data' => $mergedFormData,
+                'uploads' => $uploadResult['uploads'],
+            ]);
+
+            return [
+                'ok' => false,
+                'already_submitted' => false,
+                'message' => 'Bitte Pflichtfelder prüfen.',
+                'errors' => $errors,
+            ];
+        }
+
+        $submission = $store->saveSubmission($email, [
+            'step' => 4,
+            'form_data' => $mergedFormData,
+            'uploads' => $mergedUploads,
+        ]);
+
+        return [
+            'ok' => true,
+            'submission' => $submission,
+        ];
+    });
+} catch (Throwable $e) {
+    Bootstrap::log('app', 'submit lock error: ' . $e->getMessage());
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Abschluss derzeit nicht möglich.'], 500);
+}
+
+if (($submitResult['ok'] ?? false) !== true) {
+    $status = ($submitResult['already_submitted'] ?? false) ? 409 : 422;
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
+        'message' => (string) ($submitResult['message'] ?? 'Abschluss fehlgeschlagen.'),
+        'errors' => $submitResult['errors'] ?? [],
+    ], $status);
+}
+
+$submission = $submitResult['submission'];
+$mailer = new Mailer();
+$mailer->sendSubmissionMails($submission);
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Antrag erfolgreich übermittelt.',
+    'application_key' => $submission['application_key'] ?? null,
+]);

+ 129 - 0
assets/css/base.css

@@ -0,0 +1,129 @@
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  padding: var(--space-4);
+  background: linear-gradient(140deg, #f9fbfc 0%, #edf2f6 100%);
+  color: var(--text);
+  font-family: var(--font-body);
+}
+
+.container {
+  max-width: 900px;
+  margin: 0 auto;
+}
+
+h1,
+h2,
+h3 {
+  margin-top: 0;
+}
+
+.card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: var(--space-4);
+  margin-bottom: var(--space-4);
+}
+
+.field {
+  margin-bottom: var(--space-3);
+}
+
+label {
+  display: block;
+  margin-bottom: var(--space-1);
+  font-weight: 600;
+}
+
+input,
+select,
+textarea,
+button {
+  width: 100%;
+  padding: var(--space-2);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  font-size: 1rem;
+}
+
+input[type='checkbox'] {
+  width: auto;
+}
+
+textarea {
+  min-height: 110px;
+  resize: vertical;
+}
+
+button {
+  background: #f5f8fb;
+  cursor: pointer;
+}
+
+button:hover {
+  background: #e9f0f7;
+}
+
+.wizard-actions {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: var(--space-2);
+}
+
+.progress {
+  margin-bottom: var(--space-3);
+  color: var(--muted);
+}
+
+.error {
+  color: var(--danger);
+  min-height: 1.2rem;
+  margin-top: 0.2rem;
+  font-size: 0.9rem;
+}
+
+.hint,
+small {
+  display: block;
+  color: var(--muted);
+  margin-top: 0.25rem;
+  font-size: 0.85rem;
+}
+
+.hidden,
+.hp-field {
+  display: none;
+}
+
+.upload-item {
+  font-size: 0.9rem;
+  color: var(--muted);
+  margin-top: 0.25rem;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+th,
+td {
+  border: 1px solid var(--border);
+  padding: var(--space-2);
+  vertical-align: top;
+  text-align: left;
+}
+
+@media (max-width: 640px) {
+  body {
+    padding: var(--space-2);
+  }
+
+  .wizard-actions {
+    grid-template-columns: 1fr;
+  }
+}

+ 16 - 0
assets/css/tokens.css

@@ -0,0 +1,16 @@
+:root {
+  --bg: #f6f8fa;
+  --surface: #ffffff;
+  --text: #1c2733;
+  --muted: #586574;
+  --border: #d7dee5;
+  --primary: #005b96;
+  --danger: #a11d2d;
+  --radius: 10px;
+  --space-1: 0.5rem;
+  --space-2: 0.75rem;
+  --space-3: 1rem;
+  --space-4: 1.5rem;
+  --space-5: 2rem;
+  --font-body: 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}

+ 287 - 0
assets/js/form.js

@@ -0,0 +1,287 @@
+(function () {
+  const boot = window.APP_BOOT || { steps: [], csrf: '' };
+  const state = {
+    email: '',
+    currentStep: 1,
+    totalSteps: boot.steps.length,
+    autosaveId: null,
+  };
+
+  const startForm = document.getElementById('startForm');
+  const wizardSection = document.getElementById('wizardSection');
+  const blockedSection = document.getElementById('blockedSection');
+  const statusSection = document.getElementById('statusSection');
+  const statusMessage = document.getElementById('statusMessage');
+  const applicationForm = document.getElementById('applicationForm');
+  const applicationEmail = document.getElementById('applicationEmail');
+  const progress = document.getElementById('progress');
+  const prevBtn = document.getElementById('prevBtn');
+  const nextBtn = document.getElementById('nextBtn');
+  const submitBtn = document.getElementById('submitBtn');
+  const uploadNowBtn = document.getElementById('uploadNowBtn');
+
+  const stepElements = Array.from(document.querySelectorAll('.step'));
+
+  function showMessage(text) {
+    statusSection.classList.remove('hidden');
+    statusMessage.textContent = text;
+  }
+
+  function clearErrors() {
+    document.querySelectorAll('[data-error-for]').forEach((el) => {
+      el.textContent = '';
+    });
+  }
+
+  function showErrors(errors) {
+    clearErrors();
+    Object.keys(errors || {}).forEach((key) => {
+      const el = document.querySelector('[data-error-for="' + key + '"]');
+      if (el) {
+        el.textContent = errors[key];
+      }
+    });
+  }
+
+  function updateProgress() {
+    progress.textContent = 'Schritt ' + state.currentStep + ' von ' + state.totalSteps;
+    stepElements.forEach((el) => {
+      const step = Number(el.getAttribute('data-step'));
+      el.classList.toggle('hidden', step !== state.currentStep);
+    });
+
+    prevBtn.disabled = state.currentStep === 1;
+    nextBtn.classList.toggle('hidden', state.currentStep === state.totalSteps);
+    submitBtn.classList.toggle('hidden', state.currentStep !== state.totalSteps);
+  }
+
+  function fillFormData(data) {
+    Object.keys(data || {}).forEach((key) => {
+      const field = applicationForm.querySelector('[name="form_data[' + key + ']"]');
+      if (!field) return;
+
+      if (field.type === 'checkbox') {
+        field.checked = ['1', 'on', 'true', true].includes(data[key]);
+      } else {
+        field.value = data[key] || '';
+      }
+    });
+  }
+
+  function renderUploadInfo(uploads) {
+    document.querySelectorAll('[data-upload-list]').forEach((el) => {
+      el.innerHTML = '';
+    });
+
+    Object.keys(uploads || {}).forEach((field) => {
+      const target = document.querySelector('[data-upload-list="' + field + '"]');
+      if (!target || !Array.isArray(uploads[field])) return;
+
+      uploads[field].forEach((item) => {
+        const div = document.createElement('div');
+        div.className = 'upload-item';
+        div.textContent = item.original_filename + ' (' + item.uploaded_at + ')';
+        target.appendChild(div);
+      });
+    });
+  }
+
+  async function postForm(url, formData) {
+    const response = await fetch(url, {
+      method: 'POST',
+      body: formData,
+      credentials: 'same-origin',
+      headers: { 'X-Requested-With': 'XMLHttpRequest' },
+    });
+
+    const payload = await response.json();
+    if (!response.ok || payload.ok === false) {
+      const err = new Error(payload.message || 'Anfrage fehlgeschlagen');
+      err.payload = payload;
+      throw err;
+    }
+
+    return payload;
+  }
+
+  function collectPayload(includeFiles) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('email', state.email);
+    fd.append('step', String(state.currentStep));
+    fd.append('website', '');
+
+    Array.from(applicationForm.elements).forEach((el) => {
+      if (!el.name) return;
+      if (!el.name.startsWith('form_data[')) return;
+
+      if (el.type === 'checkbox') {
+        fd.append(el.name, el.checked ? '1' : '0');
+      } else {
+        fd.append(el.name, el.value || '');
+      }
+    });
+
+    if (includeFiles) {
+      Array.from(applicationForm.querySelectorAll('input[type="file"]')).forEach((input) => {
+        if (input.files && input.files[0]) {
+          fd.append(input.name, input.files[0]);
+        }
+      });
+    }
+
+    return fd;
+  }
+
+  async function loadDraft(email) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('email', email);
+    fd.append('website', '');
+
+    return postForm('/api/load-draft.php', fd);
+  }
+
+  async function saveDraft(includeFiles, showSavedText) {
+    const payload = collectPayload(includeFiles);
+    const response = await postForm('/api/save-draft.php', payload);
+
+    if (response.upload_errors && Object.keys(response.upload_errors).length > 0) {
+      showErrors(response.upload_errors);
+      showMessage('Einige Dateien konnten nicht gespeichert werden.');
+    } else if (showSavedText) {
+      showMessage('Entwurf gespeichert: ' + (response.updated_at || '')); 
+    }
+
+    if (response.uploads) {
+      renderUploadInfo(response.uploads);
+    }
+
+    if (includeFiles) {
+      Array.from(applicationForm.querySelectorAll('input[type="file"]')).forEach((input) => {
+        input.value = '';
+      });
+    }
+
+    return response;
+  }
+
+  async function submitApplication() {
+    const payload = collectPayload(true);
+    const response = await postForm('/api/submit.php', payload);
+    clearErrors();
+    showMessage('Antrag erfolgreich abgeschlossen. Vielen Dank.');
+    submitBtn.disabled = true;
+    nextBtn.disabled = true;
+    prevBtn.disabled = true;
+    return response;
+  }
+
+  startForm.addEventListener('submit', async (event) => {
+    event.preventDefault();
+
+    const emailInput = document.getElementById('startEmail');
+    const email = (emailInput.value || '').trim().toLowerCase();
+    if (!email) {
+      showMessage('Bitte E-Mail eingeben.');
+      return;
+    }
+
+    try {
+      const result = await loadDraft(email);
+      state.email = email;
+      applicationEmail.value = email;
+
+      if (result.already_submitted) {
+        wizardSection.classList.add('hidden');
+        blockedSection.classList.remove('hidden');
+        showMessage(result.message || 'Antrag bereits abgeschlossen.');
+        return;
+      }
+
+      blockedSection.classList.add('hidden');
+      wizardSection.classList.remove('hidden');
+
+      fillFormData(result.data || {});
+      renderUploadInfo(result.uploads || {});
+
+      state.currentStep = Math.min(Math.max(Number(result.step || 1), 1), state.totalSteps);
+      updateProgress();
+
+      showMessage('Formular geladen. Entwurf wird automatisch gespeichert.');
+
+      if (state.autosaveId) {
+        clearInterval(state.autosaveId);
+      }
+      state.autosaveId = setInterval(async () => {
+        if (!state.email || wizardSection.classList.contains('hidden')) {
+          return;
+        }
+        try {
+          await saveDraft(false, false);
+        } catch (_err) {
+          // Autsave errors are visible on next manual action.
+        }
+      }, 15000);
+    } catch (err) {
+      const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
+      showMessage(msg);
+    }
+  });
+
+  prevBtn.addEventListener('click', async () => {
+    if (state.currentStep <= 1) return;
+
+    try {
+      await saveDraft(false, true);
+      state.currentStep -= 1;
+      updateProgress();
+    } catch (err) {
+      const msg = (err.payload && err.payload.message) || err.message;
+      showMessage(msg);
+    }
+  });
+
+  nextBtn.addEventListener('click', async () => {
+    if (state.currentStep >= state.totalSteps) return;
+
+    try {
+      await saveDraft(false, true);
+      state.currentStep += 1;
+      updateProgress();
+    } catch (err) {
+      const msg = (err.payload && err.payload.message) || err.message;
+      showMessage(msg);
+    }
+  });
+
+  if (uploadNowBtn) {
+    uploadNowBtn.addEventListener('click', async () => {
+      try {
+        await saveDraft(true, true);
+      } catch (err) {
+        const msg = (err.payload && err.payload.message) || err.message;
+        showMessage(msg);
+      }
+    });
+  }
+
+  submitBtn.addEventListener('click', async () => {
+    try {
+      await submitApplication();
+    } catch (err) {
+      const payload = err.payload || {};
+      if (payload.errors) {
+        showErrors(payload.errors);
+      }
+      const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
+      showMessage(msg);
+      if (payload.already_submitted) {
+        blockedSection.classList.remove('hidden');
+        wizardSection.classList.add('hidden');
+      }
+    }
+  });
+
+  updateProgress();
+})();

+ 7 - 0
bin/.htaccess

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

+ 60 - 0
bin/cleanup.php

@@ -0,0 +1,60 @@
+#!/usr/bin/env php
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Storage\FileSystem;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init(false);
+
+$app = Bootstrap::config('app');
+$draftDir = rtrim((string) $app['storage']['drafts'], '/');
+$submissionDir = rtrim((string) $app['storage']['submissions'], '/');
+$uploadDir = rtrim((string) $app['storage']['uploads'], '/');
+
+$draftDays = (int) ($app['retention']['draft_days'] ?? 14);
+$submissionDays = (int) ($app['retention']['submission_days'] ?? 90);
+
+$now = time();
+$deletedDrafts = 0;
+$deletedSubmissions = 0;
+
+$draftFiles = glob($draftDir . '/*.json') ?: [];
+foreach ($draftFiles as $file) {
+    $raw = file_get_contents($file);
+    $data = is_string($raw) ? json_decode($raw, true) : null;
+    $updatedAt = strtotime((string) ($data['updated_at'] ?? ''));
+
+    if ($updatedAt > 0 && ($now - $updatedAt) > ($draftDays * 86400)) {
+        unlink($file);
+        $deletedDrafts++;
+    }
+}
+
+$submissionFiles = glob($submissionDir . '/*.json') ?: [];
+foreach ($submissionFiles as $file) {
+    $raw = file_get_contents($file);
+    $data = is_string($raw) ? json_decode($raw, true) : null;
+    $submittedAt = strtotime((string) ($data['submitted_at'] ?? ''));
+
+    if ($submittedAt > 0 && ($now - $submittedAt) > ($submissionDays * 86400)) {
+        $applicationKey = (string) ($data['application_key'] ?? pathinfo($file, PATHINFO_FILENAME));
+        unlink($file);
+        FileSystem::removeTree($uploadDir . '/' . $applicationKey);
+        $deletedSubmissions++;
+    }
+}
+
+$message = sprintf(
+    'Cleanup abgeschlossen: drafts=%d, submissions=%d',
+    $deletedDrafts,
+    $deletedSubmissions
+);
+
+Bootstrap::log('cleanup', $message);
+
+if (PHP_SAPI === 'cli') {
+    echo $message . PHP_EOL;
+}

+ 7 - 0
config/.htaccess

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

+ 44 - 0
config/app.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+$root = dirname(__DIR__);
+
+return [
+    'project_name' => 'Feuerwehrverein Mitgliedsantrag',
+    'base_url' => '',
+    'contact_email' => 'vorstand@example.org',
+    'retention' => [
+        'draft_days' => 14,
+        'submission_days' => 90,
+    ],
+    'uploads' => [
+        'max_size' => 10 * 1024 * 1024,
+        'allowed_extensions' => ['pdf', 'jpg', 'jpeg', 'png', 'webp'],
+        'allowed_mimes' => [
+            'application/pdf',
+            'image/jpeg',
+            'image/png',
+            'image/webp',
+        ],
+    ],
+    'rate_limit' => [
+        'requests' => 30,
+        'window_seconds' => 300,
+    ],
+    'admin' => [
+        // Hash mit password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT) erzeugen.
+        'password_hash' => '',
+        // Fallback nur für initiales Setup, danach löschen.
+        'password_plain_fallback' => 'change-me-now',
+        'session_timeout_seconds' => 3600,
+    ],
+    'storage' => [
+        'drafts' => $root . '/storage/drafts',
+        'submissions' => $root . '/storage/submissions',
+        'uploads' => $root . '/storage/uploads',
+        'rate_limit' => $root . '/storage/rate_limit',
+        'logs' => $root . '/storage/logs',
+        'locks' => $root . '/storage/locks',
+    ],
+];

+ 65 - 0
config/form_schema.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+return [
+    'steps' => [
+        [
+            'title' => 'Persönliche Daten',
+            'description' => 'Bitte geben Sie Ihre persönlichen Daten ein.',
+            'fields' => [
+                ['key' => 'vorname', 'label' => 'Vorname', 'type' => 'text', 'required' => true, 'max_length' => 100],
+                ['key' => 'nachname', 'label' => 'Nachname', 'type' => 'text', 'required' => true, 'max_length' => 100],
+                ['key' => 'geburtsdatum', 'label' => 'Geburtsdatum', 'type' => 'date', 'required' => true],
+                ['key' => 'strasse', 'label' => 'Straße und Hausnummer', 'type' => 'text', 'required' => true, 'max_length' => 200],
+                ['key' => 'plz', 'label' => 'PLZ', 'type' => 'text', 'required' => true, 'max_length' => 10],
+                ['key' => 'ort', 'label' => 'Ort', 'type' => 'text', 'required' => true, 'max_length' => 100],
+                ['key' => 'telefon', 'label' => 'Telefon', 'type' => 'text', 'required' => true, 'max_length' => 50],
+            ],
+        ],
+        [
+            'title' => 'Mitgliedschaft',
+            'description' => 'Angaben zur gewünschten Mitgliedschaft.',
+            'fields' => [
+                ['key' => 'mitgliedsart', 'label' => 'Mitgliedsart', 'type' => 'select', 'required' => true, 'options' => [
+                    ['value' => 'Aktiv', 'label' => 'Aktiv'],
+                    ['value' => 'Jugend', 'label' => 'Jugend'],
+                    ['value' => 'Foerdernd', 'label' => 'Fördernd'],
+                ]],
+                ['key' => 'abteilung', 'label' => 'Abteilung', 'type' => 'select', 'required' => true, 'options' => [
+                    ['value' => 'Einsatz', 'label' => 'Einsatzabteilung'],
+                    ['value' => 'Jugend', 'label' => 'Jugendfeuerwehr'],
+                    ['value' => 'Verein', 'label' => 'Vereinsmitglied'],
+                ]],
+                ['key' => 'ist_minderjaehrig', 'label' => 'Sind Sie unter 18 Jahren?', 'type' => 'select', 'required' => true, 'options' => [
+                    ['value' => 'nein', 'label' => 'Nein'],
+                    ['value' => 'ja', 'label' => 'Ja'],
+                ]],
+                ['key' => 'qualifikation_vorhanden', 'label' => 'Feuerwehr-Qualifikationen vorhanden?', 'type' => 'select', 'required' => true, 'options' => [
+                    ['value' => 'nein', 'label' => 'Nein'],
+                    ['value' => 'ja', 'label' => 'Ja'],
+                ]],
+                ['key' => 'bemerkung', 'label' => 'Bemerkung (optional)', 'type' => 'textarea', 'required' => false, 'max_length' => 1000],
+            ],
+        ],
+        [
+            'title' => 'Uploads',
+            'description' => 'Bitte laden Sie die erforderlichen Unterlagen hoch.',
+            'fields' => [
+                ['key' => 'portraitfoto', 'label' => 'Portraitfoto', 'type' => 'file', 'required' => true, 'accept' => '.jpg,.jpeg,.png,.webp'],
+                ['key' => 'ausweisnachweis', 'label' => 'Ausweisnachweis', 'type' => 'file', 'required' => true, 'accept' => '.pdf,.jpg,.jpeg,.png'],
+                ['key' => 'qualifikationsnachweise', 'label' => 'Qualifikationsnachweise', 'type' => 'file', 'required' => false, 'required_if' => ['field' => 'qualifikation_vorhanden', 'equals' => 'ja'], 'accept' => '.pdf,.jpg,.jpeg,.png'],
+                ['key' => 'einverstaendniserklaerung', 'label' => 'Einverständniserklärung Erziehungsberechtigte', 'type' => 'file', 'required' => false, 'required_if' => ['field' => 'ist_minderjaehrig', 'equals' => 'ja'], 'accept' => '.pdf,.jpg,.jpeg,.png'],
+                ['key' => 'zusatzunterlagen', 'label' => 'Zusatzunterlagen (optional)', 'type' => 'file', 'required' => false, 'accept' => '.pdf,.jpg,.jpeg,.png,.webp'],
+            ],
+        ],
+        [
+            'title' => 'Einwilligung & Abschluss',
+            'description' => 'Bitte bestätigen Sie die Angaben und reichen Sie den Antrag ein.',
+            'fields' => [
+                ['key' => 'einwilligung_datenschutz', 'label' => 'Ich stimme der Verarbeitung meiner Daten zu.', 'type' => 'checkbox', 'required' => true],
+                ['key' => 'einwilligung_ordnung', 'label' => 'Ich erkenne die Satzung und Ordnung des Vereins an.', 'type' => 'checkbox', 'required' => true],
+            ],
+        ],
+    ],
+];

+ 15 - 0
config/mail.php

@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+return [
+    'from' => 'no-reply@example.org',
+    'recipients' => [
+        'vorstand@example.org',
+        'kasse@example.org',
+    ],
+    'subjects' => [
+        'admin' => 'Neuer Mitgliedsantrag Feuerwehrverein',
+        'applicant' => 'Bestätigung Ihres Mitgliedsantrags',
+    ],
+];

+ 7 - 0
docs/.htaccess

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

+ 57 - 0
docs/AI_OVERVIEW.md

@@ -0,0 +1,57 @@
+# AI Overview
+
+## Ziel
+
+Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admin-Backend.
+
+## Architekturkarte
+
+- Frontend Wizard: `index.php` + `assets/js/form.js`
+- API:
+  - `api/load-draft.php`
+  - `api/save-draft.php`
+  - `api/submit.php`
+- Admin:
+  - `admin/login.php`
+  - `admin/index.php`
+  - `admin/application.php`
+  - `admin/download.php`
+  - `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`
+
+## Datenfluss
+
+1. Nutzer gibt E-Mail ein.
+2. `load-draft` prüft zuerst Submission (Unique-Constraint), dann Draft.
+3. Wizard speichert regelmäßig per `save-draft`.
+4. Uploads werden in `storage/uploads/{application_key}/{field}/{rand8}/{original_filename}` geschrieben.
+5. `submit` führt atomaren Lock + Validierung + Submission + Mailversand aus.
+6. Admin liest Submission-JSONs und bietet Downloads.
+
+## Apache-Hinweis
+
+- Deploy auf Apache mit `mod_rewrite`.
+- `AllowOverride All` muss aktiv sein, damit die Root-`.htaccess` greift.
+- Sensitive Ordner werden via `.htaccess` blockiert.
+
+## Änderungs-Guide
+
+- Neue Formularfelder: `config/form_schema.php`
+- Pflichtregeln ändern: `config/form_schema.php` (`required` / `required_if`)
+- Upload-Typen/Limits: `config/app.php` + optional pro Feld im Schema
+- Admin-Session/Login: `config/app.php` + `src/Admin/Auth.php`
+- Mailtexte/Empfänger: `config/mail.php` + `src/Mail/Mailer.php`
+- Retention-Tage: `config/app.php` + Cron `bin/cleanup.php`
+
+## Harte Regeln
+
+- Ein Antrag pro E-Mail (Submission blockiert weitere Anträge).
+- Drafts: 14 Tage, Submissions: 90 Tage.
+- Keine DB-Pflicht, Flatfile-only.

+ 45 - 0
docs/FORM_SCHEMA.md

@@ -0,0 +1,45 @@
+# Form Schema
+
+## Ort
+
+`config/form_schema.php`
+
+## Struktur
+
+```php
+[
+  'steps' => [
+    [
+      'title' => '...',
+      'description' => '...',
+      'fields' => [
+        [
+          'key' => 'feldname',
+          'label' => 'Label',
+          'type' => 'text|email|date|select|textarea|checkbox|file',
+          'required' => true|false,
+          'required_if' => ['field' => 'anderes_feld', 'equals' => 'Wert'],
+          'options' => [['value' => 'x', 'label' => 'X']],
+          'accept' => '.pdf,.jpg',
+          'max_length' => 100,
+          'max_size' => 10485760,
+          'extensions' => ['pdf','jpg'],
+          'mimes' => ['application/pdf','image/jpeg'],
+        ]
+      ]
+    ]
+  ]
+]
+```
+
+## Validierungslogik
+
+- `required: true` macht Feld immer verpflichtend.
+- `required_if` macht Feld verpflichtend, wenn Quellfeld exakt `equals` entspricht.
+- Upload-Pflicht wird gegen vorhandene Upload-Metadaten geprüft.
+
+## Upload-Verhalten
+
+- Original-Dateiname wird serverseitig bereinigt und erhalten.
+- Speichern in kurzem Random-Unterordner (`rand8`) zur Kollisionsvermeidung.
+- Metadaten werden im Draft/Submission JSON abgelegt.

+ 51 - 0
docs/OPERATIONS.md

@@ -0,0 +1,51 @@
+# Operations
+
+## Apache
+
+- Server: Apache HTTPD
+- Erforderlich: `mod_rewrite` aktiv
+- Erforderlich: `AllowOverride All` im Projektpfad
+- Root-`.htaccess` muss vorhanden sein und wird für Routing/Zugriffsschutz genutzt.
+
+## Cron Cleanup
+
+Täglich ausführen:
+
+```bash
+php /pfad/zum/projekt/bin/cleanup.php
+```
+
+## Retention
+
+- Drafts: `config/app.php -> retention.draft_days` (Default 14)
+- Submissions: `config/app.php -> retention.submission_days` (Default 90)
+
+## Logs
+
+- `storage/logs/cleanup.log`
+- `storage/logs/mail.log`
+- `storage/logs/app.log`
+
+## Backup
+
+Regelmäßig sichern:
+
+- `storage/submissions/`
+- `storage/uploads/`
+- `config/`
+
+`storage/drafts/` ist temporär und kann bei Bedarf ausgeschlossen werden.
+
+## Restore
+
+1. Projektdateien deployen.
+2. Backup von `storage/submissions` und `storage/uploads` zurückspielen.
+3. `config/` wiederherstellen.
+4. Schreibrechte prüfen.
+
+## Troubleshooting
+
+- Keine Mails: Mailfunktion des Hosters prüfen, `mail.log` ansehen.
+- Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
+- Login geht nicht: `admin.password_hash` prüfen, ggf. temporär `password_plain_fallback` nutzen.
+- ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.

+ 145 - 0
index.php

@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Form\FormSchema;
+use App\Security\Csrf;
+
+require __DIR__ . '/src/autoload.php';
+Bootstrap::init();
+
+$schema = new FormSchema();
+$steps = $schema->getSteps();
+$csrf = Csrf::token();
+$app = Bootstrap::config('app');
+
+/** @param array<string, mixed> $field */
+function renderField(array $field): void
+{
+    $key = htmlspecialchars((string) $field['key']);
+    $label = htmlspecialchars((string) $field['label']);
+    $type = (string) ($field['type'] ?? 'text');
+    $required = ((bool) ($field['required'] ?? false)) ? 'required' : '';
+
+    echo '<div class="field" data-field="' . $key . '">';
+
+    if ($type === 'checkbox') {
+        echo '<label class="checkbox-label"><input type="checkbox" name="form_data[' . $key . ']" value="1" ' . $required . '> ' . $label . '</label>';
+    } else {
+        echo '<label for="' . $key . '">' . $label . '</label>';
+
+        if ($type === 'textarea') {
+            echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '></textarea>';
+        } elseif ($type === 'select') {
+            echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
+            echo '<option value="">Bitte wählen</option>';
+            foreach (($field['options'] ?? []) as $option) {
+                if (!is_array($option)) {
+                    continue;
+                }
+                $value = htmlspecialchars((string) ($option['value'] ?? ''));
+                $optLabel = htmlspecialchars((string) ($option['label'] ?? ''));
+                echo '<option value="' . $value . '">' . $optLabel . '</option>';
+            }
+            echo '</select>';
+        } elseif ($type === 'file') {
+            $accept = htmlspecialchars((string) ($field['accept'] ?? ''));
+            echo '<input id="' . $key . '" type="file" name="' . $key . '" accept="' . $accept . '">';
+            echo '<small>Original-Dateiname wird übernommen und im System sicher gespeichert.</small>';
+            echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
+        } else {
+            $inputType = htmlspecialchars($type);
+            echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . '>';
+        }
+    }
+
+    if (isset($field['required_if']) && is_array($field['required_if'])) {
+        $depField = htmlspecialchars((string) ($field['required_if']['field'] ?? ''));
+        $depValue = htmlspecialchars((string) ($field['required_if']['equals'] ?? ''));
+        echo '<small class="hint">Pflicht, wenn ' . $depField . ' = ' . $depValue . '.</small>';
+    }
+
+    echo '<div class="error" data-error-for="' . $key . '"></div>';
+    echo '</div>';
+}
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title><?= htmlspecialchars((string) $app['project_name']) ?></title>
+    <link rel="stylesheet" href="/assets/css/tokens.css">
+    <link rel="stylesheet" href="/assets/css/base.css">
+</head>
+<body>
+<main class="container">
+    <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
+
+    <section id="startSection" class="card">
+        <h2>Start</h2>
+        <p>Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
+        <form id="startForm" novalidate>
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="hp-field" aria-hidden="true">
+                <label for="website">Website</label>
+                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
+            <div class="field">
+                <label for="startEmail">E-Mail</label>
+                <input id="startEmail" type="email" name="email" required>
+            </div>
+            <button type="submit">Formular laden</button>
+        </form>
+    </section>
+
+    <section id="blockedSection" class="card hidden">
+        <h2>Antrag bereits vorhanden</h2>
+        <p>Für diese E-Mail wurde bereits ein Antrag abgeschlossen. Für Rückfragen kontaktieren Sie bitte <?= htmlspecialchars((string) $app['contact_email']) ?>.</p>
+    </section>
+
+    <section id="wizardSection" class="card hidden">
+        <h2>Mitgliedsantrag</h2>
+        <div id="progress" class="progress"></div>
+        <form id="applicationForm" enctype="multipart/form-data" novalidate>
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <input type="hidden" id="applicationEmail" name="email" value="">
+            <input type="hidden" id="applicationWebsite" name="website" value="">
+
+            <?php foreach ($steps as $index => $step): ?>
+                <section class="step hidden" data-step="<?= $index + 1 ?>">
+                    <h3>Schritt <?= $index + 1 ?>: <?= htmlspecialchars((string) ($step['title'] ?? '')) ?></h3>
+                    <p><?= htmlspecialchars((string) ($step['description'] ?? '')) ?></p>
+
+                    <?php foreach (($step['fields'] ?? []) as $field): ?>
+                        <?php if (is_array($field)) { renderField($field); } ?>
+                    <?php endforeach; ?>
+
+                    <?php if ((int) ($index + 1) === 3): ?>
+                        <button type="button" id="uploadNowBtn">Dateien jetzt speichern</button>
+                    <?php endif; ?>
+                </section>
+            <?php endforeach; ?>
+
+            <div class="wizard-actions">
+                <button type="button" id="prevBtn">Zurück</button>
+                <button type="button" id="nextBtn">Weiter</button>
+                <button type="button" id="submitBtn" class="hidden">Verbindlich absenden</button>
+            </div>
+        </form>
+    </section>
+
+    <section id="statusSection" class="card hidden">
+        <h2>Status</h2>
+        <p id="statusMessage"></p>
+    </section>
+</main>
+<script>
+window.APP_BOOT = {
+    steps: <?= json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
+    csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
+};
+</script>
+<script src="/assets/js/form.js"></script>
+</body>
+</html>

+ 7 - 0
src/.htaccess

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

+ 71 - 0
src/Admin/Auth.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Admin;
+
+use App\App\Bootstrap;
+
+final class Auth
+{
+    /** @var array<string, mixed> */
+    private array $app;
+
+    public function __construct()
+    {
+        $this->app = Bootstrap::config('app');
+    }
+
+    public function isLoggedIn(): bool
+    {
+        return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
+    }
+
+    public function login(string $password): bool
+    {
+        $hash = (string) ($this->app['admin']['password_hash'] ?? '');
+        $plainFallback = (string) ($this->app['admin']['password_plain_fallback'] ?? '');
+
+        $valid = false;
+
+        if ($hash !== '' && strncmp($hash, '$2', 2) === 0) {
+            $valid = password_verify($password, $hash);
+        } elseif ($plainFallback !== '') {
+            $valid = hash_equals($plainFallback, $password);
+        }
+
+        if ($valid) {
+            $_SESSION['admin_logged_in'] = true;
+            $_SESSION['admin_login_at'] = time();
+            session_regenerate_id(true);
+            return true;
+        }
+
+        return false;
+    }
+
+    public function logout(): void
+    {
+        $_SESSION = [];
+        if (ini_get('session.use_cookies')) {
+            $params = session_get_cookie_params();
+            setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
+        }
+        session_destroy();
+    }
+
+    public function requireLogin(): void
+    {
+        $timeout = (int) ($this->app['admin']['session_timeout_seconds'] ?? 3600);
+        $loginAt = (int) ($_SESSION['admin_login_at'] ?? 0);
+
+        if ($this->isLoggedIn() && $loginAt > 0 && (time() - $loginAt) > $timeout) {
+            $this->logout();
+        }
+
+        if (!$this->isLoggedIn()) {
+            header('Location: /admin/login.php');
+            exit;
+        }
+    }
+}

+ 86 - 0
src/App/Bootstrap.php

@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\App;
+
+final class Bootstrap
+{
+    private static bool $booted = false;
+
+    /** @var array<string, mixed> */
+    private static array $config = [];
+
+    public static function init(bool $startSession = true): void
+    {
+        if (self::$booted) {
+            return;
+        }
+
+        self::$booted = true;
+
+        date_default_timezone_set('Europe/Berlin');
+
+        if ($startSession && session_status() !== PHP_SESSION_ACTIVE) {
+            session_start([
+                'cookie_httponly' => true,
+                'cookie_samesite' => 'Lax',
+                'use_strict_mode' => true,
+            ]);
+        }
+
+        self::$config['app'] = require self::rootPath() . '/config/app.php';
+        self::$config['mail'] = require self::rootPath() . '/config/mail.php';
+        self::$config['form_schema'] = require self::rootPath() . '/config/form_schema.php';
+
+        self::ensureStorageDirectories();
+    }
+
+    public static function rootPath(): string
+    {
+        return dirname(__DIR__, 2);
+    }
+
+    /** @return array<string, mixed> */
+    public static function config(string $name): array
+    {
+        return self::$config[$name] ?? [];
+    }
+
+    /** @param array<string, mixed> $payload */
+    public static function jsonResponse(array $payload, int $statusCode = 200): void
+    {
+        http_response_code($statusCode);
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        exit;
+    }
+
+    public static function log(string $channel, string $message): void
+    {
+        $app = self::config('app');
+        $logsDir = $app['storage']['logs'] ?? (self::rootPath() . '/storage/logs');
+
+        if (!is_dir($logsDir)) {
+            mkdir($logsDir, 0775, true);
+        }
+
+        $line = sprintf("[%s] %s\n", date('c'), $message);
+        file_put_contents($logsDir . '/' . $channel . '.log', $line, FILE_APPEND);
+    }
+
+    private static function ensureStorageDirectories(): void
+    {
+        $app = self::config('app');
+        $storage = $app['storage'] ?? [];
+
+        foreach ($storage as $path) {
+            if (!is_string($path)) {
+                continue;
+            }
+            if (!is_dir($path)) {
+                mkdir($path, 0775, true);
+            }
+        }
+    }
+}

+ 60 - 0
src/Form/FormSchema.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Form;
+
+use App\App\Bootstrap;
+
+final class FormSchema
+{
+    /** @var array<string, mixed> */
+    private array $schema;
+
+    public function __construct()
+    {
+        $this->schema = Bootstrap::config('form_schema');
+    }
+
+    /** @return array<int, array<string, mixed>> */
+    public function getSteps(): array
+    {
+        return $this->schema['steps'] ?? [];
+    }
+
+    /** @return array<string, array<string, mixed>> */
+    public function getAllFields(): array
+    {
+        $fields = [];
+
+        foreach ($this->getSteps() as $step) {
+            foreach (($step['fields'] ?? []) as $field) {
+                if (!isset($field['key']) || !is_string($field['key'])) {
+                    continue;
+                }
+                $fields[$field['key']] = $field;
+            }
+        }
+
+        return $fields;
+    }
+
+    /** @return array<string, array<string, mixed>> */
+    public function getUploadFields(): array
+    {
+        $uploads = [];
+        foreach ($this->getAllFields() as $key => $field) {
+            if (($field['type'] ?? '') === 'file') {
+                $uploads[$key] = $field;
+            }
+        }
+
+        return $uploads;
+    }
+
+    /** @return array<string, mixed> */
+    public function raw(): array
+    {
+        return $this->schema;
+    }
+}

+ 100 - 0
src/Form/Validator.php

@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Form;
+
+final class Validator
+{
+    /** @var array<string, array<string, mixed>> */
+    private array $fields;
+
+    public function __construct(FormSchema $schema)
+    {
+        $this->fields = $schema->getAllFields();
+    }
+
+    /**
+     * @param array<string, mixed> $data
+     * @param array<string, array<int, array<string, mixed>>> $uploads
+     * @return array<string, string>
+     */
+    public function validateSubmit(array $data, array $uploads): array
+    {
+        $errors = [];
+
+        foreach ($this->fields as $key => $field) {
+            $type = $field['type'] ?? 'text';
+            $required = $this->isRequired($field, $data);
+
+            if ($type === 'file') {
+                $hasFile = !empty($uploads[$key]);
+                if ($required && !$hasFile) {
+                    $errors[$key] = 'Dieses Upload-Feld ist erforderlich.';
+                }
+                continue;
+            }
+
+            $value = $data[$key] ?? null;
+
+            if ($required && $this->isEmptyValue($value, $type)) {
+                $errors[$key] = 'Dieses Feld ist erforderlich.';
+                continue;
+            }
+
+            if ($this->isEmptyValue($value, $type)) {
+                continue;
+            }
+
+            if ($type === 'email' && filter_var((string) $value, FILTER_VALIDATE_EMAIL) === false) {
+                $errors[$key] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
+            }
+
+            if ($type === 'select' && isset($field['options']) && is_array($field['options'])) {
+                $allowed = array_map(static fn ($item): string => (string) ($item['value'] ?? ''), $field['options']);
+                if (!in_array((string) $value, $allowed, true)) {
+                    $errors[$key] = 'Ungültige Auswahl.';
+                }
+            }
+
+            if (isset($field['max_length']) && is_int($field['max_length'])) {
+                if (strlen((string) $value) > $field['max_length']) {
+                    $errors[$key] = 'Eingabe ist zu lang.';
+                }
+            }
+        }
+
+        return $errors;
+    }
+
+    /** @param array<string, mixed> $field */
+    private function isRequired(array $field, array $data): bool
+    {
+        if (($field['required'] ?? false) === true) {
+            return true;
+        }
+
+        if (!isset($field['required_if']) || !is_array($field['required_if'])) {
+            return false;
+        }
+
+        $rule = $field['required_if'];
+        $sourceField = (string) ($rule['field'] ?? '');
+        $equals = (string) ($rule['equals'] ?? '');
+
+        if ($sourceField === '') {
+            return false;
+        }
+
+        return (string) ($data[$sourceField] ?? '') === $equals;
+    }
+
+    private function isEmptyValue(mixed $value, string $type): bool
+    {
+        if ($type === 'checkbox') {
+            return !in_array((string) $value, ['1', 'on', 'true'], true);
+        }
+
+        return $value === null || trim((string) $value) === '';
+    }
+}

+ 110 - 0
src/Mail/Mailer.php

@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Mail;
+
+use App\App\Bootstrap;
+
+final class Mailer
+{
+    /** @var array<string, mixed> */
+    private array $mailConfig;
+
+    public function __construct()
+    {
+        $this->mailConfig = Bootstrap::config('mail');
+    }
+
+    /** @param array<string, mixed> $submission */
+    public function sendSubmissionMails(array $submission): void
+    {
+        $email = (string) ($submission['email'] ?? '');
+
+        $subjectAdmin = (string) ($this->mailConfig['subjects']['admin'] ?? 'Neuer Mitgliedsantrag');
+        $subjectUser = (string) ($this->mailConfig['subjects']['applicant'] ?? 'Bestätigung Mitgliedsantrag');
+
+        $adminBody = $this->renderAdminBody($submission);
+        $userBody = $this->renderUserBody($submission);
+
+        $headers = $this->defaultHeaders();
+
+        foreach (($this->mailConfig['recipients'] ?? []) as $recipient) {
+            if (!is_string($recipient) || $recipient === '') {
+                continue;
+            }
+            $ok = @mail($recipient, $subjectAdmin, $adminBody, $headers);
+            if (!$ok) {
+                Bootstrap::log('mail', 'Versand an Admin fehlgeschlagen: ' . $recipient);
+            }
+        }
+
+        if ($email !== '') {
+            $okUser = @mail($email, $subjectUser, $userBody, $headers);
+            if (!$okUser) {
+                Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email);
+            }
+        }
+    }
+
+    /** @param array<string, mixed> $submission */
+    private function renderAdminBody(array $submission): string
+    {
+        $lines = [];
+        $lines[] = "Neuer Mitgliedsantrag eingegangen";
+        $lines[] = 'E-Mail: ' . (string) ($submission['email'] ?? '');
+        $lines[] = 'Eingereicht am: ' . (string) ($submission['submitted_at'] ?? '');
+        $lines[] = '';
+        $lines[] = 'Formulardaten:';
+
+        foreach ((array) ($submission['form_data'] ?? []) as $key => $value) {
+            $lines[] = '- ' . $key . ': ' . (is_scalar($value) ? (string) $value : json_encode($value));
+        }
+
+        $lines[] = '';
+        $lines[] = 'Uploads:';
+        foreach ((array) ($submission['uploads'] ?? []) as $field => $files) {
+            if (!is_array($files)) {
+                continue;
+            }
+            foreach ($files as $file) {
+                if (!is_array($file)) {
+                    continue;
+                }
+                $lines[] = sprintf(
+                    '- %s: %s (%s)',
+                    (string) $field,
+                    (string) ($file['original_filename'] ?? 'Datei'),
+                    (string) ($file['stored_dir'] ?? '')
+                );
+            }
+        }
+
+        return implode("\n", $lines);
+    }
+
+    /** @param array<string, mixed> $submission */
+    private function renderUserBody(array $submission): string
+    {
+        return implode("\n", [
+            'Vielen Dank für Ihren Mitgliedsantrag beim Feuerwehrverein.',
+            'Ihre Daten wurden erfolgreich übermittelt.',
+            '',
+            'E-Mail: ' . (string) ($submission['email'] ?? ''),
+            'Eingangszeit: ' . (string) ($submission['submitted_at'] ?? ''),
+            '',
+            'Bei Rückfragen kontaktieren Sie bitte den Verein.',
+        ]);
+    }
+
+    private function defaultHeaders(): string
+    {
+        $from = (string) ($this->mailConfig['from'] ?? 'no-reply@example.org');
+
+        return implode("\r\n", [
+            'MIME-Version: 1.0',
+            'Content-Type: text/plain; charset=UTF-8',
+            'From: ' . $from,
+        ]);
+    }
+}

+ 31 - 0
src/Security/Csrf.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Security;
+
+final class Csrf
+{
+    public static function token(): string
+    {
+        if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) {
+            $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
+        }
+
+        return $_SESSION['_csrf_token'];
+    }
+
+    public static function validate(?string $token): bool
+    {
+        $sessionToken = $_SESSION['_csrf_token'] ?? null;
+        if (!is_string($sessionToken) || $sessionToken === '') {
+            return false;
+        }
+
+        if (!is_string($token) || $token === '') {
+            return false;
+        }
+
+        return hash_equals($sessionToken, $token);
+    }
+}

+ 67 - 0
src/Security/RateLimiter.php

@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Security;
+
+use App\App\Bootstrap;
+
+final class RateLimiter
+{
+    private string $storageDir;
+
+    public function __construct()
+    {
+        $app = Bootstrap::config('app');
+        $this->storageDir = (string) ($app['storage']['rate_limit'] ?? Bootstrap::rootPath() . '/storage/rate_limit');
+
+        if (!is_dir($this->storageDir)) {
+            mkdir($this->storageDir, 0775, true);
+        }
+    }
+
+    public function allow(string $key, int $limit, int $windowSeconds): bool
+    {
+        $hash = hash('sha256', $key);
+        $path = $this->storageDir . '/' . $hash . '.json';
+        $now = time();
+
+        $handle = fopen($path, 'c+');
+        if ($handle === false) {
+            return true;
+        }
+
+        try {
+            if (!flock($handle, LOCK_EX)) {
+                return true;
+            }
+
+            $contents = stream_get_contents($handle);
+            $timestamps = [];
+            if (is_string($contents) && $contents !== '') {
+                $decoded = json_decode($contents, true);
+                if (is_array($decoded)) {
+                    $timestamps = array_values(array_filter($decoded, static fn ($ts): bool => is_int($ts)));
+                }
+            }
+
+            $threshold = $now - $windowSeconds;
+            $timestamps = array_values(array_filter($timestamps, static fn (int $ts): bool => $ts >= $threshold));
+
+            if (count($timestamps) >= $limit) {
+                return false;
+            }
+
+            $timestamps[] = $now;
+
+            ftruncate($handle, 0);
+            rewind($handle);
+            fwrite($handle, json_encode($timestamps));
+
+            return true;
+        } finally {
+            flock($handle, LOCK_UN);
+            fclose($handle);
+        }
+    }
+}

+ 35 - 0
src/Storage/FileSystem.php

@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Storage;
+
+final class FileSystem
+{
+    public static function removeTree(string $path): void
+    {
+        if (!is_dir($path)) {
+            return;
+        }
+
+        $items = scandir($path);
+        if ($items === false) {
+            return;
+        }
+
+        foreach ($items as $item) {
+            if ($item === '.' || $item === '..') {
+                continue;
+            }
+
+            $itemPath = $path . '/' . $item;
+            if (is_dir($itemPath)) {
+                self::removeTree($itemPath);
+            } elseif (is_file($itemPath)) {
+                unlink($itemPath);
+            }
+        }
+
+        rmdir($path);
+    }
+}

+ 163 - 0
src/Storage/FileUploadStore.php

@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Storage;
+
+use App\App\Bootstrap;
+
+final class FileUploadStore
+{
+    /** @var array<string, mixed> */
+    private array $app;
+
+    public function __construct()
+    {
+        $this->app = Bootstrap::config('app');
+    }
+
+    /**
+     * @param array<string, mixed> $files
+     * @param array<string, array<string, mixed>> $uploadFields
+     * @return array{uploads: array<string, array<int, array<string, mixed>>>, errors: array<string, string>}
+     */
+    public function processUploads(array $files, array $uploadFields, string $applicationKey): array
+    {
+        $uploaded = [];
+        $errors = [];
+
+        foreach ($uploadFields as $key => $fieldConfig) {
+            if (!isset($files[$key])) {
+                continue;
+            }
+
+            $file = $files[$key];
+            if (!is_array($file)) {
+                continue;
+            }
+
+            $errorCode = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
+            if ($errorCode === UPLOAD_ERR_NO_FILE) {
+                continue;
+            }
+            if ($errorCode !== UPLOAD_ERR_OK) {
+                $errors[$key] = 'Upload fehlgeschlagen.';
+                continue;
+            }
+
+            $tmpName = (string) ($file['tmp_name'] ?? '');
+            if ($tmpName === '' || !is_uploaded_file($tmpName)) {
+                $errors[$key] = 'Ungültige Upload-Datei.';
+                continue;
+            }
+
+            $size = (int) ($file['size'] ?? 0);
+            $maxSize = (int) ($fieldConfig['max_size'] ?? $this->app['uploads']['max_size']);
+            if ($size <= 0 || $size > $maxSize) {
+                $errors[$key] = 'Datei ist zu groß oder leer.';
+                continue;
+            }
+
+            $safeName = $this->sanitizeFilename((string) ($file['name'] ?? 'datei'));
+            $extension = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
+            $allowedExtensions = $fieldConfig['extensions'] ?? $this->app['uploads']['allowed_extensions'];
+            if (!in_array($extension, $allowedExtensions, true)) {
+                $errors[$key] = 'Dateityp ist nicht erlaubt.';
+                continue;
+            }
+
+            $mime = $this->detectMime($tmpName);
+            $allowedMimes = $fieldConfig['mimes'] ?? $this->app['uploads']['allowed_mimes'];
+            if (!in_array($mime, $allowedMimes, true)) {
+                $errors[$key] = 'MIME-Typ ist nicht erlaubt.';
+                continue;
+            }
+
+            $rand8 = bin2hex(random_bytes(4));
+            $baseDir = rtrim((string) $this->app['storage']['uploads'], '/');
+            $targetDir = sprintf('%s/%s/%s/%s', $baseDir, $applicationKey, $key, $rand8);
+
+            if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
+                $errors[$key] = 'Upload-Ordner konnte nicht erstellt werden.';
+                continue;
+            }
+
+            $targetName = $this->ensureUniqueName($targetDir, $safeName);
+            $targetPath = $targetDir . '/' . $targetName;
+
+            if (!move_uploaded_file($tmpName, $targetPath)) {
+                $errors[$key] = 'Datei konnte nicht gespeichert werden.';
+                continue;
+            }
+
+            $relativePath = sprintf('%s/%s/%s/%s', $applicationKey, $key, $rand8, $targetName);
+
+            $uploaded[$key][] = [
+                'original_filename' => $safeName,
+                'stored_dir' => sprintf('%s/%s/%s', $applicationKey, $key, $rand8),
+                'stored_filename' => $targetName,
+                'relative_path' => $relativePath,
+                'mime' => $mime,
+                'size' => filesize($targetPath) ?: $size,
+                'uploaded_at' => date('c'),
+            ];
+        }
+
+        return ['uploads' => $uploaded, 'errors' => $errors];
+    }
+
+    private function sanitizeFilename(string $name): string
+    {
+        $name = str_replace(["\0", '/', '\\'], '', $name);
+        $name = trim($name);
+        if ($name === '') {
+            return 'datei.bin';
+        }
+
+        $name = preg_replace('/[^A-Za-z0-9._ -]/', '_', $name) ?? 'datei.bin';
+        $name = preg_replace('/\s+/', ' ', $name) ?? $name;
+        $name = trim($name, " .");
+
+        if ($name === '' || $name === '.' || $name === '..') {
+            return 'datei.bin';
+        }
+
+        if (strlen($name) > 120) {
+            $ext = pathinfo($name, PATHINFO_EXTENSION);
+            $base = pathinfo($name, PATHINFO_FILENAME);
+            $base = substr($base, 0, 100);
+            $name = $ext !== '' ? $base . '.' . $ext : $base;
+        }
+
+        return $name;
+    }
+
+    private function ensureUniqueName(string $dir, string $fileName): string
+    {
+        $candidate = $fileName;
+        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
+        $base = pathinfo($fileName, PATHINFO_FILENAME);
+        $i = 1;
+
+        while (is_file($dir . '/' . $candidate)) {
+            $suffix = '_' . $i;
+            $candidate = $ext !== '' ? $base . $suffix . '.' . $ext : $base . $suffix;
+            $i++;
+        }
+
+        return $candidate;
+    }
+
+    private function detectMime(string $path): string
+    {
+        $finfo = finfo_open(FILEINFO_MIME_TYPE);
+        if ($finfo === false) {
+            return 'application/octet-stream';
+        }
+
+        $mime = finfo_file($finfo, $path);
+        finfo_close($finfo);
+
+        return is_string($mime) ? $mime : 'application/octet-stream';
+    }
+}

+ 264 - 0
src/Storage/JsonStore.php

@@ -0,0 +1,264 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Storage;
+
+use App\App\Bootstrap;
+use RuntimeException;
+
+final class JsonStore
+{
+    /** @var array<string, mixed> */
+    private array $app;
+
+    public function __construct()
+    {
+        $this->app = Bootstrap::config('app');
+    }
+
+    public function emailKey(string $email): string
+    {
+        return hash('sha256', strtolower(trim($email)));
+    }
+
+    /** @return array<string, mixed>|null */
+    public function getDraft(string $email): ?array
+    {
+        $path = $this->draftPath($email);
+        if (!is_file($path)) {
+            return null;
+        }
+
+        return $this->readJsonFile($path);
+    }
+
+    /** @return array<string, mixed>|null */
+    public function getSubmissionByEmail(string $email): ?array
+    {
+        $path = $this->submissionPath($email);
+        if (!is_file($path)) {
+            return null;
+        }
+
+        return $this->readJsonFile($path);
+    }
+
+    public function hasSubmission(string $email): bool
+    {
+        return is_file($this->submissionPath($email));
+    }
+
+    /**
+     * @param array<string, mixed> $draft
+     * @return array<string, mixed>
+     */
+    public function saveDraft(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_merge((array) ($current['form_data'] ?? []), (array) ($draft['form_data'] ?? [])),
+            'uploads' => $this->mergeUploads((array) ($current['uploads'] ?? []), (array) ($draft['uploads'] ?? [])),
+        ];
+
+        $this->writeJsonFile($this->draftPath($email), $payload);
+
+        return $payload;
+    }
+
+    /**
+     * @param array<string, mixed> $submission
+     * @return array<string, mixed>
+     */
+    public function saveSubmission(string $email, array $submission): array
+    {
+        $now = date('c');
+        $expires = date('c', time() + ((int) ($this->app['retention']['submission_days'] ?? 90) * 86400));
+        $draft = $this->getDraft($email) ?? [];
+
+        $payload = [
+            'email' => strtolower(trim($email)),
+            'application_key' => $this->emailKey($email),
+            'status' => 'submitted',
+            'created_at' => $draft['created_at'] ?? $now,
+            'updated_at' => $now,
+            'submitted_at' => $now,
+            'expires_at' => $expires,
+            'step' => $submission['step'] ?? ($draft['step'] ?? null),
+            'form_data' => (array) ($submission['form_data'] ?? []),
+            'uploads' => (array) ($submission['uploads'] ?? []),
+        ];
+
+        $this->writeJsonFile($this->submissionPath($email), $payload);
+        $this->deleteDraft($email);
+
+        return $payload;
+    }
+
+    public function deleteDraft(string $email): void
+    {
+        $path = $this->draftPath($email);
+        if (is_file($path)) {
+            unlink($path);
+        }
+    }
+
+    /** @return array<string, mixed>|null */
+    public function getSubmissionByKey(string $applicationKey): ?array
+    {
+        $safeKey = $this->normalizeApplicationKey($applicationKey);
+        if ($safeKey === null) {
+            return null;
+        }
+
+        $path = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
+        if (!is_file($path)) {
+            return null;
+        }
+
+        return $this->readJsonFile($path);
+    }
+
+    /** @return array<int, array<string, mixed>> */
+    public function listSubmissions(): array
+    {
+        $dir = (string) $this->app['storage']['submissions'];
+        $files = glob($dir . '/*.json') ?: [];
+        $list = [];
+
+        foreach ($files as $file) {
+            $item = $this->readJsonFile($file);
+            if (!is_array($item)) {
+                continue;
+            }
+            $list[] = $item;
+        }
+
+        usort(
+            $list,
+            static fn (array $a, array $b): int => strcmp((string) ($b['submitted_at'] ?? ''), (string) ($a['submitted_at'] ?? ''))
+        );
+
+        return $list;
+    }
+
+    public function deleteSubmissionByKey(string $applicationKey): void
+    {
+        $safeKey = $this->normalizeApplicationKey($applicationKey);
+        if ($safeKey === null) {
+            return;
+        }
+
+        $submissionPath = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
+        if (is_file($submissionPath)) {
+            $submission = $this->readJsonFile($submissionPath);
+            if (isset($submission['email']) && is_string($submission['email'])) {
+                $this->deleteDraft($submission['email']);
+            }
+            unlink($submissionPath);
+        }
+    }
+
+    /**
+     * @template T
+     * @param callable():T $callback
+     * @return T
+     */
+    public function withEmailLock(string $email, callable $callback): mixed
+    {
+        $locksDir = (string) $this->app['storage']['locks'];
+        if (!is_dir($locksDir)) {
+            mkdir($locksDir, 0775, true);
+        }
+
+        $lockFile = $locksDir . '/' . $this->emailKey($email) . '.lock';
+        $handle = fopen($lockFile, 'c+');
+
+        if ($handle === false) {
+            throw new RuntimeException('Lock-Datei konnte nicht geöffnet werden.');
+        }
+
+        try {
+            if (!flock($handle, LOCK_EX)) {
+                throw new RuntimeException('Lock konnte nicht gesetzt werden.');
+            }
+
+            return $callback();
+        } finally {
+            flock($handle, LOCK_UN);
+            fclose($handle);
+        }
+    }
+
+    private function draftPath(string $email): string
+    {
+        return rtrim((string) $this->app['storage']['drafts'], '/') . '/' . $this->emailKey($email) . '.json';
+    }
+
+    private function submissionPath(string $email): string
+    {
+        return rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $this->emailKey($email) . '.json';
+    }
+
+    /** @return array<string, mixed> */
+    private function readJsonFile(string $path): array
+    {
+        $raw = file_get_contents($path);
+        if ($raw === false || $raw === '') {
+            return [];
+        }
+
+        $decoded = json_decode($raw, true);
+        if (!is_array($decoded)) {
+            return [];
+        }
+
+        return $decoded;
+    }
+
+    /** @param array<string, mixed> $data */
+    private function writeJsonFile(string $path, array $data): void
+    {
+        $tmpPath = $path . '.tmp';
+        file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+        rename($tmpPath, $path);
+    }
+
+    /**
+     * @param array<string, mixed> $existing
+     * @param array<string, mixed> $incoming
+     * @return array<string, mixed>
+     */
+    private function mergeUploads(array $existing, array $incoming): array
+    {
+        foreach ($incoming as $field => $files) {
+            if (!is_array($files)) {
+                continue;
+            }
+            $existing[$field] = array_values(array_merge((array) ($existing[$field] ?? []), $files));
+        }
+
+        return $existing;
+    }
+
+    private function normalizeApplicationKey(string $key): ?string
+    {
+        $key = strtolower(trim($key));
+        if (!preg_match('/^[a-f0-9]{64}$/', $key)) {
+            return null;
+        }
+
+        return $key;
+    }
+}

+ 19 - 0
src/autoload.php

@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+spl_autoload_register(static function (string $class): void {
+    $prefix = 'App\\';
+    $baseDir = __DIR__ . '/';
+
+    if (strncmp($prefix, $class, strlen($prefix)) !== 0) {
+        return;
+    }
+
+    $relativeClass = substr($class, strlen($prefix));
+    $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
+
+    if (is_file($file)) {
+        require $file;
+    }
+});

+ 7 - 0
storage/.htaccess

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

+ 0 - 0
storage/drafts/.gitkeep


+ 0 - 0
storage/locks/.gitkeep


+ 0 - 0
storage/logs/.gitkeep


+ 0 - 0
storage/rate_limit/.gitkeep


+ 0 - 0
storage/submissions/.gitkeep


+ 0 - 0
storage/uploads/.gitkeep