Просмотр исходного кода

Merge branch 'new-intro-flow' of Medowar/feuerwehr-freising-antragsformular into main

Medowar 1 неделя назад
Родитель
Сommit
8312f4bab7

+ 0 - 0
storage/rate_limit/.gitkeep → .codex


+ 5 - 6
admin/application.php

@@ -72,6 +72,11 @@ $csrf = Csrf::token();
                         <button type="submit" class="btn btn-small">Alle Uploads als ZIP herunterladen</button>
                     </form>
                 <?php endif; ?>
+                <form method="post" action="<?= htmlspecialchars(Bootstrap::url('admin/delete.php')) ?>" onsubmit="return confirm('Antrag wirklich löschen? Der Antrag wird für alle Benutzer unwiederbringlich entfernt.');">
+                    <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+                    <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
+                    <button type="submit" class="btn btn-small">Antrag löschen</button>
+                </form>
             </div>
         </div>
 
@@ -161,12 +166,6 @@ $csrf = Csrf::token();
             <?php endforeach; ?>
         <?php endif; ?>
 
-        <h2>Löschen</h2>
-        <form method="post" action="<?= htmlspecialchars(Bootstrap::url('admin/delete.php')) ?>" onsubmit="return confirm('Antrag wirklich löschen? Der Antrag wird für alle Benutzer unwiederbringlich entfernt.');">
-            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
-            <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
-            <button type="submit" class="btn">Antrag löschen</button>
-        </form>
     </section>
 </main>
 </body>

+ 44 - 19
api/delete-upload.php

@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
-use App\Security\RateLimiter;
+use App\Security\FormAccess;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -12,27 +12,56 @@ require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
+}
+
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
 $field = trim((string) ($_POST['field'] ?? ''));
 $index = (int) ($_POST['index'] ?? -1);
 if ($field === '' || $index < 0) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiger Upload-Eintrag.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('delete_upload.invalid_upload_entry'),
+    ], 422);
 }
 
 /** @return array{path: string, dir: string}|null */
@@ -77,13 +106,6 @@ function resolveStoredUploadPath(array $entry, array $app): ?array
 }
 
 $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 {
@@ -92,7 +114,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 409,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+                'message' => Bootstrap::appMessage('delete_upload.already_submitted'),
             ];
         }
 
@@ -101,7 +123,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 404,
-                'message' => 'Kein Entwurf gefunden.',
+                'message' => Bootstrap::appMessage('delete_upload.draft_not_found'),
             ];
         }
 
@@ -111,7 +133,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 404,
-                'message' => 'Upload nicht gefunden.',
+                'message' => Bootstrap::appMessage('delete_upload.upload_not_found'),
             ];
         }
 
@@ -155,19 +177,22 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Upload konnte nicht gelöscht werden.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('delete_upload.delete_error'),
+    ], 500);
 }
 
 if (($result['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => (string) ($result['message'] ?? 'Upload konnte nicht gelöscht werden.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('delete_upload.delete_error')),
     ], (int) ($result['status'] ?? 422));
 }
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Upload gelöscht.',
+    'message' => Bootstrap::appMessage('delete_upload.success'),
     'uploads' => $result['uploads'] ?? [],
     'updated_at' => $result['updated_at'] ?? null,
 ]);

+ 31 - 13
api/load-draft.php

@@ -3,38 +3,56 @@
 declare(strict_types=1);
 
 use App\App\Bootstrap;
-use App\Storage\JsonStore;
 use App\Security\Csrf;
-use App\Security\RateLimiter;
+use App\Security\FormAccess;
+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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 $honeypot = trim((string) ($_POST['website'] ?? ''));
 if ($honeypot !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 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);
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
 $store = new JsonStore();
@@ -43,7 +61,7 @@ if ($submission !== null) {
     Bootstrap::jsonResponse([
         'ok' => true,
         'already_submitted' => true,
-        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        'message' => Bootstrap::appMessage('load_draft.already_submitted'),
     ]);
 }
 

+ 69 - 0
api/request-otp.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Mail\Mailer;
+use App\Security\Csrf;
+use App\Security\FormAccess;
+
+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);
+}
+
+$autoStartRaw = strtolower(trim((string) ($_POST['auto_start'] ?? '0')));
+$autoStart = in_array($autoStartRaw, ['1', 'true', 'yes'], true);
+
+$formAccess = new FormAccess();
+$request = $formAccess->requestOtp($email, $autoStart);
+if (($request['ok'] ?? false) !== true) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($request['message'] ?? 'Code konnte nicht angefordert werden.'),
+        'retry_after' => (int) ($request['retry_after'] ?? 0),
+    ], (int) ($request['status_code'] ?? 422));
+}
+
+if (($request['auto_skipped'] ?? false) === true) {
+    Bootstrap::jsonResponse([
+        'ok' => true,
+        'auto_skipped' => true,
+        'message' => 'Automatische Code-Anfrage in dieser Sitzung bereits erfolgt.',
+    ]);
+}
+
+$otpCode = (string) ($request['code'] ?? '');
+$ttlSeconds = (int) ($request['expires_in'] ?? $formAccess->otpTtlSeconds());
+
+$mailer = new Mailer();
+if (!$mailer->sendOtpMail($email, $otpCode, $ttlSeconds)) {
+    $formAccess->clearPendingOtp();
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => 'Code konnte nicht per E-Mail gesendet werden. Bitte später erneut versuchen.',
+    ], 500);
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Sicherheitscode wurde per E-Mail versendet.',
+    'expires_in' => $ttlSeconds,
+    'resend_available_in' => (int) ($request['cooldown_seconds'] ?? $formAccess->resendCooldownSeconds()),
+]);

+ 38 - 15
api/reset.php

@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
-use App\Security\RateLimiter;
+use App\Security\FormAccess;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -12,31 +12,51 @@ require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('reset:%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);
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
+$app = Bootstrap::config('app');
+
 $store = new JsonStore();
 
 try {
@@ -49,7 +69,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 409,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
+                'message' => Bootstrap::appMessage('reset.already_submitted'),
                 'had_draft' => $hadDraft,
                 'had_submission' => true,
             ];
@@ -69,13 +89,16 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'reset error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Daten konnten nicht gelöscht werden.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('reset.delete_error'),
+    ], 500);
 }
 
 if (($result['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => (string) ($result['message'] ?? 'Daten konnten nicht gelöscht werden.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('reset.delete_error')),
         'had_draft' => (bool) ($result['had_draft'] ?? false),
         'had_submission' => (bool) ($result['had_submission'] ?? false),
     ], (int) ($result['status'] ?? 422));
@@ -83,7 +106,7 @@ if (($result['ok'] ?? false) !== true) {
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Gespeicherte Daten wurden gelöscht.',
+    'message' => Bootstrap::appMessage('reset.success'),
     'had_draft' => (bool) ($result['had_draft'] ?? false),
     'had_submission' => (bool) ($result['had_submission'] ?? false),
 ]);

+ 37 - 16
api/save-draft.php

@@ -4,38 +4,56 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Form\FormSchema;
+use App\Security\Csrf;
+use App\Security\FormAccess;
 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 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);
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
 $step = (int) ($_POST['step'] ?? 1);
@@ -57,7 +75,7 @@ try {
         if ($store->hasSubmission($email)) {
             return [
                 'blocked' => true,
-                'message' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
+                'message' => Bootstrap::appMessage('save_draft.already_submitted'),
             ];
         }
 
@@ -72,14 +90,17 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'save-draft lock error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Speichern derzeit nicht möglich.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('save_draft.lock_error'),
+    ], 500);
 }
 
 if (($result['blocked'] ?? false) === true) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => true,
-        'message' => (string) ($result['message'] ?? 'Bereits abgeschlossen.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('save_draft.blocked_fallback')),
     ], 409);
 }
 
@@ -109,7 +130,7 @@ $draft = $store->getDraft($email);
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Entwurf gespeichert.',
+    'message' => Bootstrap::appMessage('save_draft.success'),
     'updated_at' => $draft['updated_at'] ?? null,
     'upload_errors' => $uploadResult['errors'],
     'uploads' => $draft['uploads'] ?? [],

+ 63 - 23
api/submit.php

@@ -6,10 +6,10 @@ use App\App\Bootstrap;
 use App\Form\FormSchema;
 use App\Form\Validator;
 use App\Mail\Mailer;
+use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
-use App\Security\Csrf;
-use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -17,11 +17,7 @@ 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;
-    }
+    $configured = Bootstrap::appMessage('submit.success');
 
     $contactEmail = trim((string) ($app['contact_email'] ?? ''));
     $message = str_replace(
@@ -33,32 +29,72 @@ function resolveSubmitSuccessMessage(array $app): string
     return trim($message);
 }
 
+function isMinorBirthdate(string $birthdate): bool
+{
+    $birthdate = trim($birthdate);
+    if ($birthdate === '') {
+        return false;
+    }
+
+    $date = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
+    if (!$date || $date->format('Y-m-d') !== $birthdate) {
+        return false;
+    }
+
+    $today = new DateTimeImmutable('today');
+    if ($date > $today) {
+        return false;
+    }
+
+    return $date->diff($today)->y < 18;
+}
+
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 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);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 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);
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
+$app = Bootstrap::config('app');
+
 $formDataRaw = $_POST['form_data'] ?? [];
 $formData = [];
 if (is_array($formDataRaw)) {
@@ -80,7 +116,7 @@ if ($store->hasSubmission($email)) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => true,
-        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        'message' => Bootstrap::appMessage('submit.already_submitted'),
     ], 409);
 }
 
@@ -90,7 +126,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => true,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+                'message' => Bootstrap::appMessage('submit.already_submitted'),
             ];
         }
 
@@ -99,7 +135,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => false,
-                'message' => 'Fehler bei Uploads.',
+                'message' => Bootstrap::appMessage('submit.upload_error'),
                 'errors' => $uploadResult['errors'],
             ];
         }
@@ -123,7 +159,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => false,
-                'message' => 'Bitte Pflichtfelder prüfen.',
+                'message' => Bootstrap::appMessage('submit.validation_error'),
                 'errors' => $errors,
             ];
         }
@@ -132,6 +168,7 @@ try {
             'step' => 4,
             'form_data' => $mergedFormData,
             'uploads' => $mergedUploads,
+            'is_minor_submission' => isMinorBirthdate((string) ($mergedFormData['geburtsdatum'] ?? '')),
         ]);
 
         return [
@@ -141,7 +178,10 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'submit lock error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Abschluss derzeit nicht möglich.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('submit.lock_error'),
+    ], 500);
 }
 
 if (($submitResult['ok'] ?? false) !== true) {
@@ -149,7 +189,7 @@ if (($submitResult['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
-        'message' => (string) ($submitResult['message'] ?? 'Abschluss fehlgeschlagen.'),
+        'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'errors' => $submitResult['errors'] ?? [],
     ], $status);
 }

+ 22 - 39
api/upload-preview.php

@@ -4,42 +4,44 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
-use App\Security\RateLimiter;
+use App\Security\FormAccess;
 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;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.method_not_allowed'), 405);
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_csrf'), 419);
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_email'), 422);
+}
+
+$activityRaw = $_GET['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.invalid_upload_entry'), 422);
 }
 
 /** @return string|null */
@@ -76,42 +78,23 @@ function resolveStoredPreviewPath(array $entry, array $app): ?string
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.draft_not_found'), 404);
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.upload_not_found'), 404);
 }
 
 $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;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.file_not_found'), 404);
 }
 
 $mime = (string) ($entry['mime'] ?? '');

+ 48 - 0
api/verify-otp.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\Csrf;
+use App\Security\FormAccess;
+
+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);
+}
+
+$code = trim((string) ($_POST['otp_code'] ?? ''));
+
+$formAccess = new FormAccess();
+$result = $formAccess->verifyOtp($email, $code);
+if (($result['ok'] ?? false) !== true) {
+    $reason = (string) ($result['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($result['message'] ?? 'Code konnte nicht bestätigt werden.'),
+        'auth_required' => in_array($reason, ['auth_required', 'expired', 'attempt_limit'], true),
+        'auth_expired' => false,
+        'attempts_left' => isset($result['attempts_left']) ? (int) $result['attempts_left'] : null,
+    ], (int) ($result['status_code'] ?? 422));
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'E-Mail erfolgreich bestätigt.',
+]);

+ 471 - 48
assets/js/form.js

@@ -1,15 +1,22 @@
 (function () {
   const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
+  const AUTO_OTP_SESSION_PREFIX = 'ff_member_form_auto_otp_sent_v1:';
   const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
   const baseUrl = String(boot.baseUrl || '').replace(/\/+$/, '');
   const schemaSteps = Array.isArray(boot.steps) ? boot.steps : [];
+  const verificationConfig = boot.verification && typeof boot.verification === 'object' ? boot.verification : {};
 
   const state = {
     email: '',
+    otpEmail: '',
+    isVerified: false,
+    lastUserActivityAt: Math.floor(Date.now() / 1000),
     currentStep: 1,
     totalSteps: schemaSteps.length,
     summaryStep: schemaSteps.length + 1,
     autosaveId: null,
+    otpCooldownId: null,
+    otpCooldownRemaining: 0,
     uploads: {},
     isSubmitting: false,
     summaryMissingCount: 0,
@@ -28,6 +35,13 @@
   const startEmailInput = document.getElementById('startEmail');
   const startEmailError = document.getElementById('startEmailError');
   const startSubmitBtn = document.getElementById('startSubmitBtn');
+  const otpSection = document.getElementById('otpSection');
+  const otpInfoText = document.getElementById('otpInfoText');
+  const startOtpInput = document.getElementById('startOtp');
+  const startOtpError = document.getElementById('startOtpError');
+  const verifyOtpBtn = document.getElementById('verifyOtpBtn');
+  const resendOtpBtn = document.getElementById('resendOtpBtn');
+  const otpCooldownMessage = document.getElementById('otpCooldownMessage');
   const resetDataBtn = document.getElementById('resetDataBtn');
   const compactStatusBox = document.getElementById('compactStatusBox');
   const statusEmailValue = document.getElementById('statusEmailValue');
@@ -38,6 +52,8 @@
   const wizardSection = document.getElementById('wizardSection');
   const applicationForm = document.getElementById('applicationForm');
   const applicationEmail = document.getElementById('applicationEmail');
+  const applicationWebsiteInput = document.getElementById('applicationWebsite');
+  const startWebsiteInput = document.getElementById('website');
   const progress = document.getElementById('progress');
   const prevBtn = document.getElementById('prevBtn');
   const nextBtn = document.getElementById('nextBtn');
@@ -136,6 +152,13 @@
     startEmailError.textContent = text || '';
   }
 
+  function setStartOtpError(text) {
+    if (!startOtpError) {
+      return;
+    }
+    startOtpError.textContent = text || '';
+  }
+
   function setDisclaimerError(text) {
     if (!disclaimerReadError) {
       return;
@@ -169,6 +192,19 @@
     return (email || '').trim().toLowerCase();
   }
 
+  function normalizeOtpCode(code) {
+    return String(code || '').replace(/[^\d]/g, '').slice(0, 6);
+  }
+
+  function honeypotValue() {
+    const applicationValue = applicationWebsiteInput ? String(applicationWebsiteInput.value || '').trim() : '';
+    if (applicationValue !== '') {
+      return applicationValue;
+    }
+
+    return startWebsiteInput ? String(startWebsiteInput.value || '').trim() : '';
+  }
+
   function isValidEmail(email) {
     return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
   }
@@ -197,6 +233,26 @@
     }
   }
 
+  function autoOtpSessionKey(email) {
+    return AUTO_OTP_SESSION_PREFIX + normalizeEmail(email);
+  }
+
+  function hasAutoOtpSessionFlag(email) {
+    try {
+      return sessionStorage.getItem(autoOtpSessionKey(email)) === '1';
+    } catch (_err) {
+      return false;
+    }
+  }
+
+  function markAutoOtpSessionFlag(email) {
+    try {
+      sessionStorage.setItem(autoOtpSessionKey(email), '1');
+    } catch (_err) {
+      // ignore
+    }
+  }
+
   function updateDisclaimerAcceptanceState() {
     if (!acceptDisclaimerBtn || !disclaimerReadCheckbox) {
       return;
@@ -225,6 +281,9 @@
     startIntroText.classList.add('hidden');
     startEmailField.classList.add('hidden');
     startActions.classList.add('hidden');
+    if (otpSection) {
+      otpSection.classList.add('hidden');
+    }
   }
 
   function leaveCompactStatus() {
@@ -235,11 +294,91 @@
     startActions.classList.remove('hidden');
     statusEmailValue.textContent = '-';
     setDraftStatus('Noch nicht gespeichert', false);
-    setResetActionVisible(true);
+    setResetActionVisible(false);
+  }
+
+  function showOtpSection(email, message) {
+    state.otpEmail = normalizeEmail(email);
+    if (otpSection) {
+      otpSection.classList.remove('hidden');
+    }
+    if (otpInfoText) {
+      otpInfoText.textContent = message || ('Code wurde an ' + state.otpEmail + ' gesendet.');
+      otpInfoText.classList.remove('error-text');
+    }
+    if (startOtpInput) {
+      startOtpInput.value = '';
+      startOtpInput.focus();
+    }
+    setStartOtpError('');
+  }
+
+  function hideOtpSection() {
+    if (otpSection) {
+      otpSection.classList.add('hidden');
+    }
+    if (otpInfoText) {
+      otpInfoText.textContent = '';
+    }
+    setStartOtpError('');
+    clearOtpCooldown();
+    state.otpEmail = '';
+  }
+
+  function clearOtpCooldown() {
+    if (state.otpCooldownId) {
+      clearInterval(state.otpCooldownId);
+      state.otpCooldownId = null;
+    }
+    state.otpCooldownRemaining = 0;
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = false;
+    }
+    if (otpCooldownMessage) {
+      otpCooldownMessage.textContent = '';
+    }
+  }
+
+  function setOtpCooldown(seconds) {
+    clearOtpCooldown();
+    const total = Math.max(0, Number(seconds || 0));
+    state.otpCooldownRemaining = total;
+    if (total <= 0) {
+      return;
+    }
+
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
+
+    const update = () => {
+      if (!otpCooldownMessage) {
+        return;
+      }
+
+      if (state.otpCooldownRemaining <= 0) {
+        otpCooldownMessage.textContent = '';
+        if (resendOtpBtn) {
+          resendOtpBtn.disabled = false;
+        }
+        if (state.otpCooldownId) {
+          clearInterval(state.otpCooldownId);
+          state.otpCooldownId = null;
+        }
+        return;
+      }
+
+      otpCooldownMessage.textContent = 'Neuer Code in ' + String(state.otpCooldownRemaining) + 's verfügbar.';
+      state.otpCooldownRemaining -= 1;
+    };
+
+    update();
+    state.otpCooldownId = setInterval(update, 1000);
   }
 
   function lockEmail(email) {
     state.email = email;
+    state.isVerified = true;
     applicationEmail.value = email;
     startEmailInput.value = email;
     startEmailInput.readOnly = true;
@@ -249,8 +388,10 @@
     enterCompactStatus(email);
   }
 
-  function unlockEmail(clearInput) {
+  function unlockEmail(clearInput, forgetEmail) {
+    const shouldForget = forgetEmail !== false;
     state.email = '';
+    state.isVerified = false;
     applicationEmail.value = '';
     startEmailInput.readOnly = false;
     startEmailInput.removeAttribute('aria-readonly');
@@ -259,8 +400,12 @@
       setStartEmailError('');
     }
     updateStartEmailRequiredMarker();
-    forgetRememberedEmail();
+    if (shouldForget) {
+      forgetRememberedEmail();
+    }
     leaveCompactStatus();
+    hideOtpSection();
+    setDisclaimerError('');
   }
 
   function stopAutosave() {
@@ -270,16 +415,23 @@
     }
   }
 
+  function markUserActivity() {
+    state.lastUserActivityAt = Math.floor(Date.now() / 1000);
+  }
+
   function startAutosave() {
     stopAutosave();
     state.autosaveId = setInterval(async () => {
-      if (!state.email || wizardSection.classList.contains('hidden')) {
+      if (!state.email || !state.isVerified || wizardSection.classList.contains('hidden')) {
         return;
       }
       try {
         await saveDraft(false);
-      } catch (_err) {
-        // visible on next manual action
+      } catch (err) {
+        const payload = (err && err.payload) || {};
+        if (isAuthFailurePayload(payload)) {
+          handleProtectedAuthFailure(payload);
+        }
       }
     }, 15000);
   }
@@ -1078,6 +1230,7 @@
     params.set('email', state.email);
     params.set('field', fieldKey);
     params.set('index', String(index));
+    params.set('last_user_activity_at', String(state.lastUserActivityAt));
     return appUrl('api/upload-preview.php') + '?' + params.toString();
   }
 
@@ -1085,9 +1238,10 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('field', fieldKey);
     fd.append('index', String(index));
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
 
     const response = await postForm(appUrl('api/delete-upload.php'), fd);
     renderUploadInfo(response.uploads || {});
@@ -1133,6 +1287,10 @@
         previewLink.target = '_blank';
         previewLink.rel = 'noopener noreferrer';
         previewLink.textContent = 'Vorschau';
+        previewLink.addEventListener('click', () => {
+          markUserActivity();
+          previewLink.href = buildUploadPreviewUrl(field, index);
+        });
         actions.appendChild(previewLink);
 
         const deleteBtn = document.createElement('button');
@@ -1140,6 +1298,7 @@
         deleteBtn.className = 'upload-item-btn upload-item-btn-danger';
         deleteBtn.textContent = 'Löschen';
         deleteBtn.addEventListener('click', async () => {
+          markUserActivity();
           const confirmed = window.confirm('Diesen Upload wirklich löschen?');
           if (!confirmed) {
             return;
@@ -1150,8 +1309,7 @@
             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);
+            handleProtectedError(err, 'Löschen fehlgeschlagen.', 'wizard');
           } finally {
             deleteBtn.disabled = false;
           }
@@ -1201,7 +1359,12 @@
         renderSummary();
       }
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Upload fehlgeschlagen.';
+      const wasAuth = handleProtectedError(err, 'Upload fehlgeschlagen.', 'wizard');
+      if (wasAuth) {
+        return;
+      }
+      const payload = (err && err.payload) || {};
+      const msg = payload.message || err.message || 'Upload fehlgeschlagen.';
       setDraftStatus('Upload fehlgeschlagen', true);
       setFeedback(msg, true);
     }
@@ -1255,7 +1418,13 @@
       headers: { 'X-Requested-With': 'XMLHttpRequest' },
     });
 
-    const payload = await response.json();
+    let payload = {};
+    try {
+      payload = await response.json();
+    } catch (_err) {
+      payload = {};
+    }
+
     if (!response.ok || payload.ok === false) {
       const err = new Error(payload.message || 'Anfrage fehlgeschlagen');
       err.payload = payload;
@@ -1265,12 +1434,82 @@
     return payload;
   }
 
+  function isAuthFailurePayload(payload) {
+    return Boolean(payload && (payload.auth_required || payload.auth_expired));
+  }
+
+  function inactivityInfoText() {
+    const inactivitySeconds = Math.max(60, Number(verificationConfig.inactivity_seconds || 3600));
+    const inactivityMinutes = Math.round(inactivitySeconds / 60);
+    if (inactivityMinutes % 60 === 0) {
+      const hours = inactivityMinutes / 60;
+      return 'nach ' + String(hours) + ' Stunde' + (hours === 1 ? '' : 'n') + ' Inaktivität';
+    }
+    return 'nach ' + String(inactivityMinutes) + ' Minuten Inaktivität';
+  }
+
+  function handleProtectedAuthFailure(payload) {
+    const email = normalizeEmail(state.email || startEmailInput.value || '');
+    stopAutosave();
+    wizardSection.classList.add('hidden');
+    disclaimerSection.classList.add('hidden');
+    clearWizardData();
+    unlockEmail(false, false);
+    setResetActionVisible(false);
+
+    if (email !== '') {
+      startEmailInput.value = email;
+      updateStartEmailRequiredMarker();
+    }
+
+    const isExpired = Boolean(payload && payload.auth_expired);
+    const defaultMessage = isExpired
+      ? 'Ihre Sitzung ist ' + inactivityInfoText() + ' abgelaufen. Bitte erneut verifizieren.'
+      : 'Bitte zuerst E-Mail und Sicherheitscode bestätigen.';
+
+    setFeedback((payload && payload.message) || defaultMessage, true, 'start');
+    setDraftStatus(isExpired ? 'Sitzung abgelaufen' : 'Verifizierung erforderlich', true);
+    hideOtpSection();
+    startEmailInput.focus();
+  }
+
+  function handleProtectedError(err, fallbackMessage, scope) {
+    const payload = err && err.payload ? err.payload : {};
+    if (isAuthFailurePayload(payload)) {
+      handleProtectedAuthFailure(payload);
+      return true;
+    }
+
+    const msg = payload.message || err.message || fallbackMessage;
+    setFeedback(msg, true, scope || 'wizard');
+    return false;
+  }
+
+  async function requestOtpCode(email, autoStart) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('website', honeypotValue());
+    fd.append('email', email);
+    fd.append('auto_start', autoStart ? '1' : '0');
+    return postForm(appUrl('api/request-otp.php'), fd);
+  }
+
+  async function verifyOtpCode(email, code) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('website', honeypotValue());
+    fd.append('email', email);
+    fd.append('otp_code', code);
+    return postForm(appUrl('api/verify-otp.php'), fd);
+  }
+
   function collectPayload(includeFiles) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
     fd.append('step', String(Math.min(state.currentStep, state.totalSteps)));
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
 
     Array.from(applicationForm.elements).forEach((el) => {
       if (!el.name) {
@@ -1308,7 +1547,8 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/load-draft.php'), fd);
   }
 
@@ -1316,7 +1556,8 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/reset.php'), fd);
   }
 
@@ -1412,12 +1653,29 @@
     return false;
   }
 
-  async function startProcess(rawEmail) {
+  function validateOtpCode(showError) {
+    const code = normalizeOtpCode(startOtpInput.value || '');
+    startOtpInput.value = code;
+    if (/^\d{6}$/.test(code)) {
+      setStartOtpError('');
+      return true;
+    }
+
+    if (showError) {
+      setStartOtpError('Bitte einen 6-stelligen Code eingeben.');
+    }
+
+    return false;
+  }
+
+  async function requestOtpFlow(rawEmail, options) {
+    const opts = options || {};
+    const autoStart = Boolean(opts.autoStart);
     const email = normalizeEmail(rawEmail);
     if (!isValidEmail(email)) {
       const message = 'Bitte eine gültige E-Mail-Adresse eingeben.';
       setStartEmailError(message);
-      setFeedback(message, true);
+      setFeedback(message, true, 'start');
       startEmailInput.focus();
       return;
     }
@@ -1430,14 +1688,109 @@
     if (startSubmitBtn) {
       startSubmitBtn.disabled = true;
     }
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
 
     try {
-      const result = await loadDraft(email);
+      const result = await requestOtpCode(email, autoStart);
+      if (autoStart) {
+        markAutoOtpSessionFlag(email);
+      }
+
+      if (result.auto_skipped) {
+        showOtpSection(email, 'Bitte Sicherheitscode eingeben oder einen neuen Code anfordern.');
+        setFeedback('', false, 'start');
+      } else {
+        showOtpSection(email, result.message || ('Sicherheitscode wurde an ' + email + ' gesendet.'));
+        setFeedback(result.message || 'Sicherheitscode wurde versendet.', false, 'start');
+        setOtpCooldown(Number(result.resend_available_in || 0));
+      }
+    } catch (err) {
+      const payload = (err && err.payload) || {};
+      const retryAfter = Number(payload.retry_after || 0);
+      if (retryAfter > 0) {
+        setOtpCooldown(retryAfter);
+      }
+      setFeedback(payload.message || err.message || 'Code konnte nicht gesendet werden.', true, 'start');
+    } finally {
+      if (startSubmitBtn) {
+        startSubmitBtn.disabled = false;
+      }
+      if (resendOtpBtn && state.otpCooldownRemaining <= 0) {
+        resendOtpBtn.disabled = false;
+      }
+    }
+  }
+
+  async function verifyOtpFlow() {
+    const email = normalizeEmail(startEmailInput.value || '');
+    if (!isValidEmail(email)) {
+      setStartEmailError('Bitte eine gültige E-Mail-Adresse eingeben.');
+      startEmailInput.focus();
+      return;
+    }
+
+    if (!validateOtpCode(true)) {
+      startOtpInput.focus();
+      return;
+    }
+
+    if (verifyOtpBtn) {
+      verifyOtpBtn.disabled = true;
+    }
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
+
+    try {
+      const result = await verifyOtpCode(email, normalizeOtpCode(startOtpInput.value || ''));
       lockEmail(email);
       setResetActionVisible(true);
+      hideOtpSection();
+      setFeedback(result.message || 'E-Mail erfolgreich bestätigt.', false, 'start');
+      markUserActivity();
+      disclaimerSection.classList.remove('hidden');
+      wizardSection.classList.add('hidden');
+      if (disclaimerReadCheckbox) {
+        disclaimerReadCheckbox.checked = false;
+      }
+      updateDisclaimerAcceptanceState();
+      disclaimerSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    } catch (err) {
+      const payload = (err && err.payload) || {};
+      const attemptsLeft = Number(payload.attempts_left);
+      if (Number.isFinite(attemptsLeft) && attemptsLeft >= 0 && attemptsLeft < 5) {
+        setStartOtpError((payload.message || 'Code ungültig.') + ' Verbleibende Versuche: ' + String(attemptsLeft));
+      } else {
+        setStartOtpError(payload.message || err.message || 'Code konnte nicht bestätigt werden.');
+      }
+      setFeedback(payload.message || err.message || 'Code konnte nicht bestätigt werden.', true, 'start');
+      startOtpInput.focus();
+    } finally {
+      if (verifyOtpBtn) {
+        verifyOtpBtn.disabled = false;
+      }
+      if (resendOtpBtn && state.otpCooldownRemaining <= 0) {
+        resendOtpBtn.disabled = false;
+      }
+    }
+  }
+
+  async function openWizardForVerifiedEmail() {
+    if (!state.email || !state.isVerified) {
+      setFeedback('Bitte zuerst E-Mail und Sicherheitscode bestätigen.', true, 'start');
+      return false;
+    }
+
+    try {
+      markUserActivity();
+      const result = await loadDraft(state.email);
+      setResetActionVisible(true);
 
       if (result.already_submitted) {
         wizardSection.classList.add('hidden');
+        disclaimerSection.classList.add('hidden');
         setDraftStatus('Antrag bereits abgeschlossen', false);
         setFeedback(
           result.message || 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
@@ -1446,9 +1799,10 @@
         );
         setResetActionVisible(false);
         stopAutosave();
-        return;
+        return true;
       }
 
+      disclaimerSection.classList.add('hidden');
       wizardSection.classList.remove('hidden');
       fillFormData(result.data || {});
       renderUploadInfo(result.uploads || {});
@@ -1464,14 +1818,47 @@
       } else {
         setDraftStatus('Neuer Entwurf gestartet', false);
       }
-      setFeedback('', false);
+      setFeedback('', false, 'wizard');
+      return true;
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
-      setFeedback(msg, true);
-    } finally {
-      if (startSubmitBtn) {
-        startSubmitBtn.disabled = false;
-      }
+      handleProtectedError(err, 'Laden fehlgeschlagen.', 'start');
+      return false;
+    }
+  }
+
+  async function resumeVerifiedSession(email) {
+    const normalized = normalizeEmail(email);
+    if (!isValidEmail(normalized)) {
+      return false;
+    }
+
+    lockEmail(normalized);
+    setResetActionVisible(true);
+    return openWizardForVerifiedEmail();
+  }
+
+  async function initAutoOtpForRememberedEmail() {
+    const rememberedEmail = normalizeEmail(getRememberedEmail());
+    if (rememberedEmail === '' || !isValidEmail(rememberedEmail)) {
+      return;
+    }
+
+    startEmailInput.value = rememberedEmail;
+    updateStartEmailRequiredMarker();
+
+    const resumed = await resumeVerifiedSession(rememberedEmail);
+    if (resumed) {
+      return;
+    }
+
+    if (state.isVerified) {
+      unlockEmail(false, false);
+      startEmailInput.value = rememberedEmail;
+      updateStartEmailRequiredMarker();
+    }
+
+    if (!state.isVerified && !hasAutoOtpSessionFlag(rememberedEmail)) {
+      await requestOtpFlow(rememberedEmail, { autoStart: true });
     }
   }
 
@@ -1481,7 +1868,7 @@
       startEmailInput.focus();
       return;
     }
-    await startProcess(startEmailInput.value || '');
+    await requestOtpFlow(startEmailInput.value || '', { autoStart: false });
   });
 
   startEmailInput.addEventListener('input', () => {
@@ -1494,6 +1881,9 @@
       updateStartEmailRequiredMarker();
       return;
     }
+    if (state.otpEmail !== '' && state.otpEmail !== normalizeEmail(startEmailInput.value || '')) {
+      hideOtpSection();
+    }
     validateStartEmail(false);
   });
 
@@ -1520,16 +1910,17 @@
     }
 
     try {
+      markUserActivity();
       await resetSavedData(state.email);
       stopAutosave();
       wizardSection.classList.add('hidden');
+      disclaimerSection.classList.add('hidden');
       clearWizardData();
       unlockEmail(true);
       setFeedback('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.', false);
       startEmailInput.focus();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Löschen fehlgeschlagen.', 'start');
     }
   });
 
@@ -1539,6 +1930,7 @@
     }
 
     try {
+      markUserActivity();
       if (state.currentStep <= state.totalSteps) {
         await saveDraft(false);
       }
@@ -1546,8 +1938,7 @@
       updateProgress();
       scrollWizardToTop();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message;
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Speichern fehlgeschlagen.', 'wizard');
     }
   });
 
@@ -1557,13 +1948,13 @@
     }
 
     try {
+      markUserActivity();
       await saveDraft(false);
       state.currentStep += 1;
       updateProgress();
       scrollWizardToTop();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message;
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Speichern fehlgeschlagen.', 'wizard');
     }
   });
 
@@ -1579,9 +1970,14 @@
     }
 
     try {
+      markUserActivity();
       await submitApplication();
     } catch (err) {
       const payload = err.payload || {};
+      if (isAuthFailurePayload(payload)) {
+        handleProtectedAuthFailure(payload);
+        return;
+      }
       if (payload.errors) {
         showErrors(payload.errors);
       }
@@ -1595,36 +1991,62 @@
     }
   });
 
-  function initializeAfterDisclaimer() {
-    disclaimerSection.classList.add('hidden');
-    startSection.classList.remove('hidden');
-
-    const rememberedEmail = normalizeEmail(getRememberedEmail());
-    if (rememberedEmail !== '' && isValidEmail(rememberedEmail)) {
-      startEmailInput.value = rememberedEmail;
-      setFeedback('', false);
-      startProcess(rememberedEmail);
-    }
-  }
-
   if (disclaimerReadCheckbox) {
     disclaimerReadCheckbox.addEventListener('change', updateDisclaimerAcceptanceState);
   }
 
   if (acceptDisclaimerBtn) {
-    acceptDisclaimerBtn.addEventListener('click', () => {
+    acceptDisclaimerBtn.addEventListener('click', async () => {
       if (!disclaimerReadCheckbox || !disclaimerReadCheckbox.checked) {
         setDisclaimerError('Bitte lesen und bestätigen Sie den Hinweis.');
         return;
       }
       setDisclaimerError('');
-      initializeAfterDisclaimer();
+      markUserActivity();
+      await openWizardForVerifiedEmail();
+    });
+  }
+
+  if (verifyOtpBtn) {
+    verifyOtpBtn.addEventListener('click', async () => {
+      await verifyOtpFlow();
+    });
+  }
+
+  if (resendOtpBtn) {
+    resendOtpBtn.addEventListener('click', async () => {
+      await requestOtpFlow(startEmailInput.value || '', { autoStart: false });
     });
   }
 
+  if (startOtpInput) {
+    startOtpInput.addEventListener('input', () => {
+      startOtpInput.value = normalizeOtpCode(startOtpInput.value || '');
+      setStartOtpError('');
+    });
+
+    startOtpInput.addEventListener('keydown', async (event) => {
+      if (event.key !== 'Enter') {
+        return;
+      }
+      event.preventDefault();
+      await verifyOtpFlow();
+    });
+  }
+
+  ['input', 'change', 'click', 'keydown'].forEach((eventName) => {
+    applicationForm.addEventListener(eventName, () => {
+      if (wizardSection.classList.contains('hidden')) {
+        return;
+      }
+      markUserActivity();
+    });
+  });
+
   updateDisclaimerAcceptanceState();
-  disclaimerSection.classList.remove('hidden');
-  startSection.classList.add('hidden');
+  disclaimerSection.classList.add('hidden');
+  startSection.classList.remove('hidden');
+  wizardSection.classList.add('hidden');
 
   initTableFields();
   initUploadControls();
@@ -1633,4 +2055,5 @@
   refreshRequiredMarkers();
   updateStartEmailRequiredMarker();
   updateProgress();
+  initAutoOtpForRememberedEmail();
 })();

+ 56 - 8
config/app.sample.php

@@ -7,11 +7,13 @@ $root = dirname(__DIR__);
 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%.',
+    'contact_email' => 'kontakt@example.com',
+    'start' => [
+        'intro_text' => 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.',
+    ],
     '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.",
+        'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfältig.\n\nMit dem Fortfahren bestaetigen Sie, dass Ihre Angaben vollstaendig und wahrheitsgemäß sind.\nIhre Daten werden ausschliesslich zur Bearbeitung des Mitgliedsantrags verwendet.",
         'accept_label' => 'Hinweis gelesen, weiter zum Antrag',
     ],
     'address_disclaimer' => [
@@ -31,14 +33,17 @@ return [
             'image/webp',
         ],
     ],
-    'rate_limit' => [
-        'enabled' => true,
-        'requests' => 30,
-        'window_seconds' => 300,
+    'verification' => [
+        'required' => true,
+        'inactivity_seconds' => 3600,
+        'otp_ttl_seconds' => 600,
+        'otp_attempt_limit' => 5,
+        'resend_cooldown_seconds' => 60,
     ],
     'admin' => [
         // Feste Zugangsdaten als Tabelle (hardcoded).
         // Hash mit: php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
+        // Alternativ: Online Tool: https://bcrypt-generator.com/
         'credentials' => [
             [
                 'username' => 'admin',
@@ -51,8 +56,51 @@ return [
         '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',
     ],
+    'api_messages' => [
+        'common' => [
+            'method_not_allowed' => 'Method not allowed',
+            'invalid_csrf' => 'Invalid CSRF-Token.',
+            'request_blocked' => 'Anfrage blockiert.',
+            'invalid_email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
+        ],
+        'load_draft' => [
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        ],
+        'save_draft' => [
+            'already_submitted' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
+            'lock_error' => 'Speichern derzeit nicht möglich.',
+            'blocked_fallback' => 'Bereits abgeschlossen.',
+            'success' => 'Entwurf gespeichert.',
+        ],
+        'submit' => [
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            'upload_error' => 'Fehler bei Uploads.',
+            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfeler sind ausgefüllt oder ungültige Werte vorhanden.',
+            'lock_error' => 'Abschluss derzeit nicht möglich. Debug-Info: Lock konnte nicht gesetzt werden.',
+            'failure' => 'Abschluss fehlgeschlagen.',
+            'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
+        ],
+        'reset' => [
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
+            'delete_error' => 'Daten konnten nicht gelöscht werden.',
+            'success' => 'Gespeicherte Daten wurden gelöscht.',
+        ],
+        'delete_upload' => [
+            'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            'draft_not_found' => 'Kein Entwurf gefunden.',
+            'upload_not_found' => 'Upload nicht gefunden.',
+            'delete_error' => 'Upload konnte nicht gelöscht werden.',
+            'success' => 'Upload gelöscht.',
+        ],
+        'upload_preview' => [
+            'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
+            'draft_not_found' => 'Entwurf nicht gefunden.',
+            'upload_not_found' => 'Upload nicht gefunden.',
+            'file_not_found' => 'Datei nicht gefunden.',
+        ],
+    ],
 ];

+ 65 - 16
config/form_schema.php

@@ -106,7 +106,7 @@ return [
                 ],
                 [
                     'key' => 'Erziehungsberechtigte',
-                    'label' => 'Name der Erziehungsberechtigten(Bei mehreren Erziehungsberechtigten, bitte alle Namen getrennt durch Komma eingeben)',
+                    'label' => 'Name Erziehungsberechtigte 1(bei mehreren Erziehungsberechtigten, zuerst eine Person eingeben)',
                     'type' => 'text',
                     'required_if' => [
                         'field' => 'is_minor',
@@ -120,7 +120,7 @@ return [
                 ],
                 [
                     'key' => 'Erziehungsberechtigte_telefon',
-                    'label' => 'Telefon der Erziehungsberechtigten(Mobil)',
+                    'label' => 'Telefon Erziehungsberechtigte 1 (Mobil)',
                     'type' => 'number',
                     'required_if' => [
                         'field' => 'is_minor',
@@ -134,7 +134,7 @@ return [
                 ],
                 [
                     'key' => 'Erziehungsberechtigte_email',
-                    'label' => 'E-Mail der Erziehungsberechtigten',
+                    'label' => 'E-Mail Erziehungsberechtigte 1',
                     'type' => 'email',
                     'required_if' => [
                         'field' => 'is_minor',
@@ -148,7 +148,7 @@ return [
                 ],
                 [
                     'key' => 'Erziehungsberechtigte_adresse',
-                    'label' => 'Adresse der Erziehungsberechtigten(Straße, Hausnummer, PLZ, Ort)',
+                    'label' => 'Adresse Erziehungsberechtigte 1(Straße, Hausnummer, PLZ, Ort)',
                     'type' => 'address',
                     'required_if' => [
                         'field' => 'is_minor',
@@ -160,15 +160,55 @@ return [
                     ],
                 ],
                 [
-                    'key' => 'bestätigung_eignung',
-                    'label' => 'Ich bestätige, körperlich und geistig für den Feuerwehrdienst geeignet zu sein. ',
+                    'key' => 'weitere_erziehungsberechtigte',
+                    'label' => 'Weitere Erziehungsberechtigte Person angeben',
                     'type' => 'checkbox',
-                    'required_if' => [
+                    'required' => false,
+                    'visible_if' => [
                         'field' => 'is_minor',
                         'equals' => '1',
                     ],
+                ],
+                [
+                    'key' => 'Erziehungsberechtigte_2',
+                    'label' => 'Name Erziehungsberechtigte 2(bei mehreren Erziehungsberechtigten, zweite Person eingeben)',
+                    'type' => 'text',
+                    'required_if' => [
+                        'field' => 'weitere_erziehungsberechtigte',
+                        'equals' => '1',
+                    ],
+                    'max_length' => 120,
                     'visible_if' => [
-                        'field' => 'is_minor',
+                        'field' => 'weitere_erziehungsberechtigte',
+                        'equals' => '1',
+                    ],
+                ],
+                [
+                    'key' => 'Erziehungsberechtigte_telefon_2',
+                    'label' => 'optional: Telefon Erziehungsberechtigte 2 (Mobil)',
+                    'type' => 'number',
+                    'max_length' => 50,
+                    'visible_if' => [
+                        'field' => 'weitere_erziehungsberechtigte',
+                        'equals' => '1',
+                    ],
+                ],
+                [
+                    'key' => 'Erziehungsberechtigte_email_2',
+                    'label' => 'E-Mail Erziehungsberechtigte 2(wenn abweichend)',
+                    'type' => 'email',
+                    'max_length' => 100,
+                    'visible_if' => [
+                        'field' => 'weitere_erziehungsberechtigte',
+                        'equals' => '1',
+                    ],
+                ],
+                [
+                    'key' => 'Erziehungsberechtigte_adresse_2',
+                    'label' => 'Adresse Erziehungsberechtigte 2(Straße, Hausnummer, PLZ, Ort, wenn abweichend)',
+                    'type' => 'address',
+                    'visible_if' => [
+                        'field' => 'weitere_erziehungsberechtigte',
                         'equals' => '1',
                     ],
                 ],
@@ -182,11 +222,15 @@ return [
                     ],
                 ],
                 [
-                    'key'      => 'qualifikation_vorhanden',
-                    'label'    => 'Feuerwehr-Qualifikationen bereits vorhanden?',
-                    'type'     => 'select',
+                    'key' => 'qualifikation_vorhanden',
+                    'label' => 'Feuerwehr-Qualifikationen bereits vorhanden?',
+                    'type' => 'select',
+                    'visible_if' => [
+                        'field' => 'mitgliedsart',
+                        'equals' => 'aktiv'
+                    ],
                     'required' => true,
-                    'options'  => [
+                    'options' => [
                         [
                             'value' => 'nein',
                             'label' => 'Nein',
@@ -241,10 +285,6 @@ return [
                     'key' => 'führerschein_nachweis',
                     'label' => 'Optional: Weitere Qualifikationen',
                     'type' => 'text',
-                    'visible_if' => [
-                        'field' => 'qualifikation_vorhanden',
-                        'equals' => 'ja',
-                    ],
                     'subtext' => 'z.B. Ladekran, Stapler, Feuerwehrführerschein, etc.',
                 ],
                 [
@@ -376,6 +416,15 @@ return [
                     ],
                     'subtext' => 'nur wenn vorhanden',
                 ],
+                [
+                    'key' => 'musikalische_vorerfahrung',
+                    'label' => 'Musikalische Vorerfahrung(optional, wenn vorhanden)',
+                    'type' => 'textarea',
+                    'visible_if' => [
+                        'field' => 'mitgliedsart',
+                        'equals' => 'Spielmannszug',
+                    ],
+                ],
             ],
         ],
         [

+ 6 - 1
config/mail.sample.php

@@ -10,6 +10,11 @@ return [
     ],
     'subjects' => [
         'admin' => 'Neuer Mitgliedsantrag',
-        'applicant' => 'Bestaetigung deines Mitgliedsantrags',
+        'applicant' => 'Bestätigung deines Mitgliedsantrags',
+        'otp' => 'Ihr Sicherheitscode für den Mitgliedsantrag',
+    ],
+    'otp' => [
+        'text_template' => "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.",
+        'html_template' => '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>',
     ],
 ];

+ 1 - 2
docs/ai_overview.md

@@ -39,7 +39,6 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
   - `src/storage/filesystem.php`
   - `src/form/validator.php`
   - `src/security/csrf.php`
-  - `src/security/ratelimiter.php`
   - `src/mail/mailer.php` (HTML-Mails + PDF-Anhänge)
   - `src/mail/mimemailbuilder.php` (MIME-Mails via nativer mail()-Funktion)
   - `src/mail/pdfgenerator.php` (FPDF, Antrags- und Anlagen-PDFs)
@@ -70,7 +69,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 - Admin-Session/Login: `config/app.local.php` + `admin/auth.php`
 - Mailtexte/Empfänger: `config/mail.local.php` + `src/mail/mailer.php`
 - Retention-Tage: `config/app.local.php` + Cron `admin/cleanup.php`
-- Rate-Limit-Parameter: `config/app.local.php -> rate_limit` (Details: `docs/rate_limiting.md`)
+- Honeypot-Feld im Frontend: `index.php` + `assets/js/form.js` (`website`)
 - Disclaimer-Startseite: `config/app.local.php -> disclaimer` + `index.php`
 - Versionskontrollierte Config-Vorlagen: `config/app.sample.php`, `config/mail.sample.php`
 - Lokale Runtime-Configs (nicht versioniert): `config/app.local.php`, `config/mail.local.php`

+ 0 - 2
docs/initial_setup.md

@@ -47,7 +47,6 @@ Diese Werte müssen gesetzt/validiert werden:
   - `submission_success_message`
   - `disclaimer.*`
   - `retention.*`
-  - `rate_limit.*`
   - `uploads.*`
   - `storage.*` (falls Speicherorte abweichend sein sollen)
 
@@ -76,7 +75,6 @@ Mindestens diese Verzeichnisse müssen für PHP schreibbar sein:
 - `storage/drafts`
 - `storage/submissions`
 - `storage/uploads`
-- `storage/rate_limit`
 - `storage/logs`
 - `storage/locks`
 

+ 4 - 7
docs/operations.md

@@ -38,13 +38,10 @@ php /pfad/zum/projekt/admin/cleanup.php
 - `storage/logs/php_runtime.log`
 - `storage/logs/php_fatal.log`
 
-## Rate Limiting
+## Bot-Schutz (Honeypot)
 
-- Konfiguration: `config/app.local.php -> rate_limit`
-- Persistenz: `storage/rate_limit/`
-- Detaillierte Doku: `docs/rate_limiting.md`
-- Bei erhöhten `429`-Antworten zuerst `requests/window_seconds` prüfen und gegen reale Nutzerlast kalibrieren.
-- Für Tests kann das Limiting global deaktiviert werden: `rate_limit.enabled = false`.
+- Das öffentliche Formular nutzt das Honeypot-Feld `website` als einfachen Spam-Schutz.
+- Anfragen mit nicht-leerem `website` werden mit HTTP `400` und `common.request_blocked` abgewiesen.
 
 ## Backup
 
@@ -69,5 +66,5 @@ Regelmäßig sichern:
 - Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
 - Login geht nicht: `config/app.local.php -> admin.credentials` prüfen (username + password_hash).
 - ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.
-- Viele `429` Antworten: `docs/rate_limiting.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
+- Unerwartet blockierte Requests (`400 Anfrage blockiert`): prüfen, ob das Feld `website` durch Client/Proxy/Plugin befüllt wird.
 - 500 ohne Apache/PHP-Fehlerausgabe: `storage/logs/php_fatal.log` und `storage/logs/php_runtime.log` prüfen.

+ 0 - 65
docs/rate_limiting.md

@@ -1,65 +0,0 @@
-# Rate Limiting
-
-## Zweck
-
-Schützt die API gegen Spam, Bot-Traffic und Missbrauch durch zu viele Anfragen in kurzer Zeit.
-
-## Implementierung
-
-- Klasse: `src/security/ratelimiter.php`
-- Strategie: Sliding-Window auf Basis von Zeitstempeln
-- Persistenz: Flat Files in `storage/rate_limit/`
-- Schlüsselablage: pro Key wird `sha256(key).json` verwendet
-- Sperren: `flock(LOCK_EX)` je Key-Datei
-
-## Konfiguration
-
-In `config/app.local.php`:
-
-- `rate_limit.enabled`
-Globaler Schalter (`true`/`false`). Bei `false` lässt der Limiter alle Requests durch.
-- `rate_limit.requests`
-Maximal erlaubte Requests pro Zeitfenster
-- `rate_limit.window_seconds`
-Länge des Zeitfensters in Sekunden
-
-Default:
-
-- `enabled = true`
-- `requests = 30`
-- `window_seconds = 300` (5 Minuten)
-
-## Aktuell geschützte Endpunkte
-
-- `POST /api/load-draft.php`
-  - Key: `load:{ip}:{email}`
-- `POST /api/save-draft.php`
-  - Key: `save:{ip}:{email}`
-- `POST /api/submit.php`
-  - Key: `submit:{ip}:{email}`
-- `POST /api/reset.php`
-  - Key: `reset:{ip}:{email}`
-
-Hinweis: Jeder Endpunkt hat einen eigenen Prefix (`load/save/submit/reset`) und damit ein separates Limit-Fenster.
-
-## Verhalten bei Limitüberschreitung
-
-API antwortet mit HTTP `429` und einer Fehlermeldung (z. B. „Zu viele Anfragen“).
-
-## Betriebsaspekte
-
-- Viele Dateien in `storage/rate_limit/` sind normal.
-- Verzeichnis muss für den Webserver beschreibbar sein.
-- Löschen einzelner Dateien setzt das Limit für den betreffenden Key zurück.
-- Komplettes Leeren des Ordners setzt alle Limits zurück.
-
-## Tuning-Empfehlungen
-
-- Höherer Schutz: `requests` senken oder `window_seconds` erhöhen.
-- Weniger strenger Schutz: `requests` erhöhen oder `window_seconds` senken.
-- Bei aggressiven Bot-Wellen zuerst `submit` härter setzen (ggf. zukünftig endpoint-spezifische Limits einführen).
-
-## Bekannte Eigenschaften
-
-- Bei Dateisystemproblemen (Datei kann nicht geöffnet/gesperrt werden) erlaubt der Limiter aktuell die Anfrage (fail-open), um legitime Nutzer nicht zu blockieren.
-- NAT/Shared-IP kann mehrere legitime Nutzer unter derselben IP bündeln; durch Kombination mit E-Mail ist das Risiko reduziert.

+ 30 - 6
index.php

@@ -25,6 +25,13 @@ if (is_string($disclaimerConfigRaw)) {
 $disclaimerTitle = (string) ($disclaimerConfig['title'] ?? 'Hinweis');
 $disclaimerText = (string) ($disclaimerConfig['text'] ?? '');
 $disclaimerAcceptLabel = (string) ($disclaimerConfig['accept_label'] ?? 'Hinweis gelesen, weiter');
+$startConfigRaw = $app['start'] ?? [];
+if (is_array($startConfigRaw)) {
+    $startConfig = $startConfigRaw;
+} else {
+    $startConfig = [];
+}
+$startIntroText = (string) ($startConfig['intro_text'] ?? 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.');
 $addressDisclaimerConfigRaw = $app['address_disclaimer'] ?? ($app['address_disclaimer_text'] ?? '');
 if (is_string($addressDisclaimerConfigRaw)) {
     $addressDisclaimerText = $addressDisclaimerConfigRaw;
@@ -215,7 +222,7 @@ function renderField(array $field, string $addressDisclaimerText): void
 <main class="container">
     <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
 
-    <section id="disclaimerSection" class="card">
+    <section id="disclaimerSection" class="card hidden">
         <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
         <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
         <div class="field disclaimer-ack-field">
@@ -235,7 +242,10 @@ function renderField(array $field, string $addressDisclaimerText): void
         <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="">
+            <div class="hp-field" aria-hidden="true">
+                <label for="applicationWebsite">Website</label>
+                <input id="applicationWebsite" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
 
             <?php foreach ($steps as $index => $step): ?>
                 <section class="step hidden" data-step="<?= $index + 1 ?>">
@@ -267,9 +277,9 @@ function renderField(array $field, string $addressDisclaimerText): void
         </form>
     </section>
 
-    <section id="startSection" class="card hidden">
+    <section id="startSection" class="card">
         <h2>Status</h2>
-        <p id="startIntroText">Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
+        <p id="startIntroText"><?= htmlspecialchars($startIntroText) ?></p>
         <form id="startForm" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <div class="hp-field" aria-hidden="true">
@@ -282,8 +292,21 @@ function renderField(array $field, string $addressDisclaimerText): void
                 <div id="startEmailError" class="error"></div>
             </div>
             <div class="inline-actions" id="startActions">
-                <button id="startSubmitBtn" type="submit" class="btn">Formular laden</button>
+                <button id="startSubmitBtn" type="submit" class="btn">Code senden</button>
             </div>
+            <section id="otpSection" class="hidden" aria-live="polite">
+                <p id="otpInfoText" class="status-text"></p>
+                <div class="field" id="startOtpField">
+                    <label for="startOtp">Sicherheitscode <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                    <input id="startOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
+                    <div id="startOtpError" class="error"></div>
+                </div>
+                <div class="inline-actions" id="otpActions">
+                    <button id="verifyOtpBtn" type="button" class="btn">Code bestätigen</button>
+                    <button id="resendOtpBtn" type="button" class="btn btn-secondary">Code erneut senden</button>
+                </div>
+                <p id="otpCooldownMessage" class="status-text"></p>
+            </section>
             <div id="compactStatusBox" class="compact-status hidden">
                 <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
                 <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
@@ -298,7 +321,8 @@ window.APP_BOOT = {
     steps: <?= json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
     csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
     contactEmail: <?= json_encode((string) ($app['contact_email'] ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
-    baseUrl: <?= json_encode($baseUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
+    baseUrl: <?= json_encode($baseUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
+    verification: <?= json_encode((array) ($app['verification'] ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
 };
 </script>
 <script src="<?= htmlspecialchars(Bootstrap::url('assets/js/form.js')) ?>"></script>

+ 2 - 4
readme.md

@@ -46,7 +46,7 @@ Detaillierte Schritt-für-Schritt-Anleitung: `docs/initial_setup.md`
    - `cp config/app.sample.php config/app.local.php`
    - `cp config/mail.sample.php config/mail.local.php`
 6. Konfiguration anpassen:
-   - `config/app.local.php` (Admin-Credentials, Kontakt, Disclaimer, Retention, Rate Limit)
+   - `config/app.local.php` (Admin-Credentials, Kontakt, Disclaimer, Retention)
    - `config/mail.local.php` (Absender, Empfänger)
 7. Admin-Credential setzen:
    - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`
@@ -63,8 +63,7 @@ Hinweis:
 ## Sicherheitshinweise
 
 - CSRF aktiv auf POST-Endpunkten.
-- Honeypot + Rate Limit aktiv.
-- Rate Limit fuer Tests deaktivierbar ueber `config/app.local.php -> rate_limit.enabled = false`.
+- Honeypot (Feld `website`) aktiv.
 - Uploads werden auf Typ, MIME und Größe geprüft.
 - Interne Ordner (`config`, `src`, `storage`, `docs`, `lib`) werden per `.htaccess` blockiert.
 
@@ -84,5 +83,4 @@ Lokale PHP-Laufzeit wird benötigt (CLI + Webserver), um Syntaxchecks/Tests ausz
 - `docs/initial_setup.md`
 - `docs/form_schema.md`
 - `docs/operations.md`
-- `docs/rate_limiting.md`
 - `docs/auth_integration.md`

+ 30 - 0
src/app/bootstrap.php

@@ -74,6 +74,28 @@ final class Bootstrap
         return ($base !== '' ? $base : '') . '/' . $normalizedPath;
     }
 
+    public static function appMessage(string $path, string $fallback = ''): string
+    {
+        $value = self::config('app')['api_messages'] ?? [];
+        if (!is_array($value)) {
+            return $fallback;
+        }
+
+        foreach (explode('.', $path) as $segment) {
+            if (!is_array($value) || !array_key_exists($segment, $value)) {
+                return $fallback;
+            }
+            $value = $value[$segment];
+        }
+
+        if (!is_string($value)) {
+            return $fallback;
+        }
+
+        $message = trim($value);
+        return $message !== '' ? $message : $fallback;
+    }
+
     /** @param array<string, mixed> $payload */
     public static function jsonResponse(array $payload, int $statusCode = 200): void
     {
@@ -83,6 +105,14 @@ final class Bootstrap
         exit;
     }
 
+    public static function textResponse(string $message, int $statusCode = 200): void
+    {
+        http_response_code($statusCode);
+        header('Content-Type: text/plain; charset=utf-8');
+        echo $message;
+        exit;
+    }
+
     public static function log(string $channel, string $message): void
     {
         $app = self::config('app');

+ 158 - 11
src/mail/mailer.php

@@ -11,27 +11,62 @@ final class Mailer
 {
     /** @var array<string, mixed> */
     private array $mailConfig;
+    /** @var array<string, mixed> */
+    private array $appConfig;
     private SubmissionFormatter $formatter;
     private PdfGenerator $pdfGenerator;
 
     public function __construct()
     {
         $this->mailConfig = Bootstrap::config('mail');
+        $this->appConfig = Bootstrap::config('app');
         $schema = new FormSchema();
         $this->formatter = new SubmissionFormatter($schema);
         $this->pdfGenerator = new PdfGenerator($this->formatter, $schema);
     }
 
+    public function sendOtpMail(string $email, string $code, int $ttlSeconds): bool
+    {
+        $email = strtolower(trim($email));
+        if ($email === '') {
+            return false;
+        }
+
+        $subject = (string) ($this->mailConfig['subjects']['otp'] ?? 'Ihr Sicherheitscode');
+        $textBody = $this->renderOtpText($code, $ttlSeconds);
+        $htmlBody = $this->renderOtpHtml($code, $ttlSeconds);
+
+        try {
+            $mail = $this->createMailBuilder();
+            $mail->setTo($email)
+                ->setSubject($subject)
+                ->setTextBody($textBody)
+                ->setHtmlBody($htmlBody);
+
+            if (!$mail->send()) {
+                Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo());
+                return false;
+            }
+        } catch (\Throwable $e) {
+            Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $e->getMessage());
+            return false;
+        }
+
+        return true;
+    }
+
     /** @param array<string, mixed> $submission */
     public function sendSubmissionMails(array $submission): void
     {
         $formDataPdf = $this->pdfGenerator->generateFormDataPdf($submission);
         $attachmentsPdf = $this->pdfGenerator->generateAttachmentsPdf($submission);
         $pdfAttachments = $this->pdfGenerator->collectPdfAttachments($submission);
+        $minorSignaturePdf = $this->pdfGenerator->generateMinorSignaturePdf($submission);
+        $isMinorSubmission = $this->isMinorSubmission($submission);
 
         try {
-            $this->sendAdminMails($submission, $formDataPdf, $attachmentsPdf, $pdfAttachments);
-            $this->sendApplicantMail($submission);
+            $this->sendAdminMails($submission, $formDataPdf, $attachmentsPdf, $pdfAttachments, $isMinorSubmission);
+            $this->sendApplicantMail($submission, $minorSignaturePdf);
         } finally {
             if ($formDataPdf !== null) {
                 @unlink($formDataPdf);
@@ -39,6 +74,9 @@ final class Mailer
             if ($attachmentsPdf !== null) {
                 @unlink($attachmentsPdf);
             }
+            if ($minorSignaturePdf !== null) {
+                @unlink($minorSignaturePdf);
+            }
         }
     }
 
@@ -51,12 +89,13 @@ final class Mailer
         ?string $formDataPdf,
         ?string $attachmentsPdf,
         array $pdfAttachments,
+        bool $isMinorSubmission,
     ): void {
         $recipients = (array) ($this->mailConfig['recipients'] ?? []);
         $subject = (string) ($this->mailConfig['subjects']['admin'] ?? 'Neuer Mitgliedsantrag');
 
-        $htmlBody = $this->renderAdminHtml($submission);
-        $textBody = $this->renderAdminText($submission);
+        $htmlBody = $this->renderAdminHtml($submission, $isMinorSubmission);
+        $textBody = $this->renderAdminText($submission, $isMinorSubmission);
 
         foreach ($recipients as $recipient) {
             if (!is_string($recipient) || $recipient === '') {
@@ -90,16 +129,17 @@ final class Mailer
     }
 
     /** @param array<string, mixed> $submission */
-    private function sendApplicantMail(array $submission): void
+    private function sendApplicantMail(array $submission, ?string $minorSignaturePdf): void
     {
         $email = (string) ($submission['email'] ?? '');
         if ($email === '') {
             return;
         }
 
+        $isMinorSubmission = $this->isMinorSubmission($submission);
         $subject = (string) ($this->mailConfig['subjects']['applicant'] ?? 'Bestätigung Mitgliedsantrag');
-        $htmlBody = $this->renderApplicantHtml($submission);
-        $textBody = $this->renderApplicantText($submission);
+        $htmlBody = $this->renderApplicantHtml($submission, $isMinorSubmission);
+        $textBody = $this->renderApplicantText($submission, $isMinorSubmission);
 
         try {
             $mail = $this->createMailBuilder();
@@ -108,6 +148,10 @@ final class Mailer
                 ->setHtmlBody($htmlBody)
                 ->setTextBody($textBody);
 
+            if ($minorSignaturePdf !== null && is_file($minorSignaturePdf)) {
+                $mail->addAttachment($minorSignaturePdf, 'Einverstaendniserklaerung-Minderjaehrige.pdf', 'application/pdf');
+            }
+
             if (!$mail->send()) {
                 Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo());
             }
@@ -133,7 +177,7 @@ final class Mailer
     // ---------------------------------------------------------------
 
     /** @param array<string, mixed> $submission */
-    private function renderAdminHtml(array $submission): string
+    private function renderAdminHtml(array $submission, bool $isMinorSubmission): string
     {
         $steps = $this->formatter->formatSteps($submission);
         $uploads = $this->formatter->formatUploads($submission);
@@ -142,6 +186,9 @@ final class Mailer
         $h .= '<h2 style="color:#c0392b">Neuer Mitgliedsantrag</h2>';
         $h .= '<p>Eingereicht: ' . $this->esc($this->formatTimestamp($submission)) . '<br>';
         $h .= 'E-Mail: ' . $this->esc((string) ($submission['email'] ?? '')) . '</p>';
+        if ($isMinorSubmission) {
+            $h .= $this->renderMinorAdminNoticeHtml();
+        }
 
         foreach ($steps as $step) {
             $h .= '<h3 style="border-bottom:2px solid #c0392b;padding-bottom:4px">' . $this->esc($step['title']) . '</h3>';
@@ -170,7 +217,7 @@ final class Mailer
     }
 
     /** @param array<string, mixed> $submission */
-    private function renderAdminText(array $submission): string
+    private function renderAdminText(array $submission, bool $isMinorSubmission): string
     {
         $steps = $this->formatter->formatSteps($submission);
         $uploads = $this->formatter->formatUploads($submission);
@@ -178,6 +225,9 @@ final class Mailer
         $t = "NEUER MITGLIEDSANTRAG\n";
         $t .= 'Eingereicht: ' . $this->formatTimestamp($submission) . "\n";
         $t .= 'E-Mail: ' . (string) ($submission['email'] ?? '') . "\n\n";
+        if ($isMinorSubmission) {
+            $t .= $this->renderMinorAdminNoticeText() . "\n\n";
+        }
 
         foreach ($steps as $step) {
             $t .= strtoupper($step['title']) . "\n" . str_repeat('-', 40) . "\n";
@@ -204,7 +254,7 @@ final class Mailer
     // ---------------------------------------------------------------
 
     /** @param array<string, mixed> $submission */
-    private function renderApplicantHtml(array $submission): string
+    private function renderApplicantHtml(array $submission, bool $isMinorSubmission): string
     {
         $steps = $this->formatter->formatSteps($submission);
         $uploads = $this->formatter->formatUploads($submission);
@@ -212,6 +262,11 @@ final class Mailer
         $h = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;color:#333;max-width:700px;margin:0 auto">';
         $h .= '<h2 style="color:#c0392b">Vielen Dank für Ihren Mitgliedsantrag!</h2>';
         $h .= '<p>Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.</p>';
+        if ($isMinorSubmission) {
+            $h .= '<p><strong>Hinweis für Minderjährige:</strong> '
+                . 'Das angehängte Formular ist auszudrucken, von Antragsteller/in und Erziehungsberechtigten zu unterschreiben '
+                . 'und persönlich einzureichen.</p>';
+        }
 
         foreach ($steps as $step) {
             $h .= '<h3>' . $this->esc($step['title']) . '</h3>';
@@ -241,13 +296,17 @@ final class Mailer
     }
 
     /** @param array<string, mixed> $submission */
-    private function renderApplicantText(array $submission): string
+    private function renderApplicantText(array $submission, bool $isMinorSubmission): string
     {
         $steps = $this->formatter->formatSteps($submission);
         $uploads = $this->formatter->formatUploads($submission);
 
         $t = "Vielen Dank für Ihren Mitgliedsantrag!\n\n";
         $t .= "Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.\n\n";
+        if ($isMinorSubmission) {
+            $t .= "Hinweis für Minderjährige: Das angehängte Formular ist auszudrucken, von Antragsteller/in und "
+                . "Erziehungsberechtigten zu unterschreiben und persönlich einzureichen.\n\n";
+        }
 
         foreach ($steps as $step) {
             $t .= strtoupper($step['title']) . "\n";
@@ -287,4 +346,92 @@ final class Mailer
     {
         return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
     }
+
+    private function renderMinorAdminNoticeHtml(): string
+    {
+        return '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
+            . '<strong>Wichtiger Hinweis (Minderjaehrig):</strong> '
+            . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
+            . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
+            . '</div>';
+    }
+
+    private function renderMinorAdminNoticeText(): string
+    {
+        return "WICHTIGER HINWEIS (MINDERJAEHRIG)\n"
+            . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
+            . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.';
+    }
+
+    /** @param array<string, mixed> $submission */
+    private function isMinorSubmission(array $submission): bool
+    {
+        if (array_key_exists('is_minor_submission', $submission)) {
+            return (bool) $submission['is_minor_submission'];
+        }
+
+        $formData = (array) ($submission['form_data'] ?? []);
+        return $this->deriveIsMinorFromBirthdate((string) ($formData['geburtsdatum'] ?? ''));
+    }
+
+    private function deriveIsMinorFromBirthdate(string $birthdate): bool
+    {
+        $birthdate = trim($birthdate);
+        if ($birthdate === '') {
+            return false;
+        }
+
+        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
+        if (!$date || $date->format('Y-m-d') !== $birthdate) {
+            return false;
+        }
+
+        $today = new \DateTimeImmutable('today');
+        if ($date > $today) {
+            return false;
+        }
+
+        return $date->diff($today)->y < 18;
+    }
+
+    private function renderOtpText(string $code, int $ttlSeconds): string
+    {
+        $configured = (string) ($this->mailConfig['otp']['text_template'] ?? '');
+        $template = trim($configured);
+        if ($template === '') {
+            $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.";
+        }
+
+        return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, false);
+    }
+
+    private function renderOtpHtml(string $code, int $ttlSeconds): string
+    {
+        $configured = (string) ($this->mailConfig['otp']['html_template'] ?? '');
+        $template = trim($configured);
+        if ($template === '') {
+            $template = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>';
+        }
+
+        return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, true);
+    }
+
+    private function replaceOtpTemplateVars(string $template, string $code, int $ttlSeconds, bool $htmlContext): string
+    {
+        $minutes = (string) max(1, (int) ceil($ttlSeconds / 60));
+        $projectName = (string) ($this->appConfig['project_name'] ?? 'Mitgliedsantrag');
+        $safeCode = trim($code);
+        $safeProjectName = trim($projectName);
+        if ($htmlContext) {
+            $safeCode = $this->esc($safeCode);
+            $safeProjectName = $this->esc($safeProjectName);
+        }
+
+        return strtr($template, [
+            '{{code}}' => $safeCode,
+            '{{ttl_seconds}}' => (string) max(1, $ttlSeconds),
+            '{{ttl_minutes}}' => $minutes,
+            '{{project_name}}' => $safeProjectName,
+        ]);
+    }
 }

+ 126 - 0
src/mail/pdfgenerator.php

@@ -80,6 +80,71 @@ final class PdfGenerator
         }
     }
 
+    /**
+     * Generates a printable consent/signature document for minors.
+     * Returns null for adult submissions.
+     *
+     * @param array<string, mixed> $submission
+     */
+    public function generateMinorSignaturePdf(array $submission): ?string
+    {
+        if (!$this->isMinorSubmission($submission)) {
+            return null;
+        }
+
+        try {
+            $pdf = $this->createPdf();
+            $pdf->SetTitle($this->enc('Einverstaendniserklaerung Minderjaehrige'), true);
+            $pdf->AddPage();
+
+            $pdf->SetFont('Helvetica', 'B', 15);
+            $pdf->Cell(0, 10, $this->enc('Einverstaendniserklaerung fuer Minderjaehrige'), 0, 1);
+            $pdf->SetFont('Helvetica', '', 9);
+            $pdf->Cell(0, 5, $this->enc('Eingereicht: ' . $this->formatTimestamp($submission)), 0, 1);
+            $pdf->Cell(0, 5, $this->enc('E-Mail: ' . (string) ($submission['email'] ?? '')), 0, 1);
+            $pdf->Ln(3);
+
+            $pdf->SetFont('Helvetica', '', 10);
+            $pdf->MultiCell(
+                0,
+                5,
+                $this->enc('Dieses Dokument ist auszudrucken, handschriftlich zu unterschreiben und persoenlich einzureichen.')
+            );
+            $pdf->Ln(2);
+
+            $steps = $this->formatter->formatSteps($submission);
+            foreach ($steps as $step) {
+                $this->renderStepSection($pdf, $step);
+            }
+
+            $uploads = $this->formatter->formatUploads($submission);
+            if ($uploads !== []) {
+                $this->ensureSpace($pdf, 20);
+                $pdf->SetFont('Helvetica', 'B', 12);
+                $pdf->Cell(0, 8, $this->enc('Hochgeladene Dateien'), 0, 1);
+                $pdf->SetFont('Helvetica', '', 10);
+                foreach ($uploads as $group) {
+                    $pdf->SetFont('Helvetica', 'B', 10);
+                    $pdf->Cell(0, 6, $this->enc($group['label'] . ':'), 0, 1);
+                    $pdf->SetFont('Helvetica', '', 10);
+                    foreach ($group['files'] as $name) {
+                        $pdf->Cell(5);
+                        $pdf->Cell(0, 5, $this->enc('- ' . $name), 0, 1);
+                    }
+                }
+            }
+
+            $this->renderMinorSignatureSection($pdf);
+
+            $tmpPath = $this->tempPath('minderjaehrige_einverstaendnis');
+            $pdf->Output('F', $tmpPath);
+            return $tmpPath;
+        } catch (\Throwable $e) {
+            Bootstrap::log('mail', 'PDF-Erstellung (Minderjaehrigen-Erklaerung) fehlgeschlagen: ' . $e->getMessage());
+            return null;
+        }
+    }
+
     /**
      * Compiles all non-portrait image uploads into a single PDF.
      * Returns null if there are no image uploads.
@@ -240,6 +305,36 @@ final class PdfGenerator
         }
     }
 
+    private function renderMinorSignatureSection(\FPDF $pdf): void
+    {
+        $this->ensureSpace($pdf, 55);
+        $pdf->Ln(6);
+        $pdf->SetFont('Helvetica', 'B', 11);
+        $pdf->Cell(0, 8, $this->enc('Unterschriften'), 0, 1);
+        $pdf->SetFont('Helvetica', '', 10);
+        $pdf->MultiCell(
+            0,
+            5,
+            $this->enc('Hiermit bestaetigen Antragsteller/in und Erziehungsberechtigte/r die Richtigkeit der oben aufgefuehrten Angaben.')
+        );
+        $pdf->Ln(10);
+
+        $lineWidth = 80.0;
+        $leftX = self::MARGIN;
+        $rightX = $pdf->GetPageWidth() - self::MARGIN - $lineWidth;
+        $lineY = $pdf->GetY();
+
+        $pdf->Line($leftX, $lineY, $leftX + $lineWidth, $lineY);
+        $pdf->Line($rightX, $lineY, $rightX + $lineWidth, $lineY);
+
+        $pdf->SetY($lineY + 2);
+        $pdf->SetFont('Helvetica', '', 9);
+        $pdf->SetX($leftX);
+        $pdf->Cell($lineWidth, 5, $this->enc('Antragsteller/in (minderjaehrig)'), 0, 0);
+        $pdf->SetX($rightX);
+        $pdf->Cell($lineWidth, 5, $this->enc('Erziehungsberechtigte/r (Eltern)'), 0, 1);
+    }
+
     private function embedImage(\FPDF $pdf, string $path): void
     {
         $size = @getimagesize($path);
@@ -355,6 +450,37 @@ final class PdfGenerator
     // Utilities
     // ---------------------------------------------------------------
 
+    /** @param array<string, mixed> $submission */
+    private function isMinorSubmission(array $submission): bool
+    {
+        if (array_key_exists('is_minor_submission', $submission)) {
+            return (bool) $submission['is_minor_submission'];
+        }
+
+        $formData = (array) ($submission['form_data'] ?? []);
+        return $this->deriveIsMinorFromBirthdate((string) ($formData['geburtsdatum'] ?? ''));
+    }
+
+    private function deriveIsMinorFromBirthdate(string $birthdate): bool
+    {
+        $birthdate = trim($birthdate);
+        if ($birthdate === '') {
+            return false;
+        }
+
+        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
+        if (!$date || $date->format('Y-m-d') !== $birthdate) {
+            return false;
+        }
+
+        $today = new \DateTimeImmutable('today');
+        if ($date > $today) {
+            return false;
+        }
+
+        return $date->diff($today)->y < 18;
+    }
+
     private function createPdf(): \FPDF
     {
         $pdf = new \FPDF('P', 'mm', 'A4');

+ 12 - 6
src/mail/submissionformatter.php

@@ -203,15 +203,21 @@ final class SubmissionFormatter
     /** @param array<string, mixed> $formData */
     private function deriveIsMinor(array $formData): string
     {
-        $dob = (string) ($formData['geburtsdatum'] ?? '');
-        if ($dob === '') {
+        $birthdate = trim((string) ($formData['geburtsdatum'] ?? ''));
+        if ($birthdate === '') {
             return '0';
         }
-        $ts = strtotime($dob);
-        if ($ts === false) {
+
+        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
+        if (!$date || $date->format('Y-m-d') !== $birthdate) {
+            return '0';
+        }
+
+        $today = new \DateTimeImmutable('today');
+        if ($date > $today) {
             return '0';
         }
-        $age = (int) ((time() - $ts) / 31557600);
-        return $age < 18 ? '1' : '0';
+
+        return $date->diff($today)->y < 18 ? '1' : '0';
     }
 }

+ 365 - 0
src/security/formaccess.php

@@ -0,0 +1,365 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Security;
+
+use App\App\Bootstrap;
+
+final class FormAccess
+{
+    private const SESSION_KEY = '_form_access';
+
+    private bool $required;
+    private int $inactivitySeconds;
+    private int $otpTtlSeconds;
+    private int $otpAttemptLimit;
+    private int $resendCooldownSeconds;
+
+    public function __construct()
+    {
+        $app = Bootstrap::config('app');
+        $verification = is_array($app['verification'] ?? null) ? $app['verification'] : [];
+
+        $this->required = (bool) ($verification['required'] ?? true);
+        $this->inactivitySeconds = max(60, (int) ($verification['inactivity_seconds'] ?? 3600));
+        $this->otpTtlSeconds = max(60, (int) ($verification['otp_ttl_seconds'] ?? 600));
+        $this->otpAttemptLimit = max(1, (int) ($verification['otp_attempt_limit'] ?? 5));
+        $this->resendCooldownSeconds = max(5, (int) ($verification['resend_cooldown_seconds'] ?? 60));
+    }
+
+    public function isRequired(): bool
+    {
+        return $this->required;
+    }
+
+    public function inactivitySeconds(): int
+    {
+        return $this->inactivitySeconds;
+    }
+
+    public function otpTtlSeconds(): int
+    {
+        return $this->otpTtlSeconds;
+    }
+
+    public function otpAttemptLimit(): int
+    {
+        return $this->otpAttemptLimit;
+    }
+
+    public function resendCooldownSeconds(): int
+    {
+        return $this->resendCooldownSeconds;
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string, retry_after?: int, cooldown_seconds?: int, expires_in?: int, code?: string, auto_skipped?: bool}
+     */
+    public function requestOtp(string $email, bool $autoStart = false): array
+    {
+        if (!$this->required) {
+            return [
+                'ok' => true,
+                'auto_skipped' => true,
+            ];
+        }
+
+        $email = $this->normalizeEmail($email);
+        $now = time();
+
+        if ($email === '') {
+            return [
+                'ok' => false,
+                'reason' => 'invalid_email',
+                'status_code' => 422,
+                'message' => 'Bitte gültige E-Mail eingeben.',
+            ];
+        }
+
+        if ($autoStart && $this->hasAutoOtpSentForEmail($email)) {
+            return [
+                'ok' => true,
+                'auto_skipped' => true,
+            ];
+        }
+
+        $state =& $this->state();
+        $pending = $this->pendingState();
+        $pendingEmail = $this->normalizeEmail((string) ($pending['email'] ?? ''));
+        $cooldownUntil = (int) ($pending['cooldown_until'] ?? 0);
+        if ($pendingEmail === $email && $cooldownUntil > $now) {
+            return [
+                'ok' => false,
+                'reason' => 'cooldown',
+                'status_code' => 429,
+                'retry_after' => $cooldownUntil - $now,
+                'message' => 'Bitte kurz warten, bevor ein neuer Code gesendet wird.',
+            ];
+        }
+
+        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+        $state['pending_otp'] = [
+            'email' => $email,
+            'hash' => $this->hashOtp($email, $code),
+            'expires_at' => $now + $this->otpTtlSeconds,
+            'attempts' => 0,
+            'cooldown_until' => $now + $this->resendCooldownSeconds,
+        ];
+
+        if ($autoStart) {
+            $this->markAutoOtpSentForEmail($email);
+        }
+
+        return [
+            'ok' => true,
+            'code' => $code,
+            'expires_in' => $this->otpTtlSeconds,
+            'cooldown_seconds' => $this->resendCooldownSeconds,
+        ];
+    }
+
+    public function clearPendingOtp(): void
+    {
+        $state =& $this->state();
+        unset($state['pending_otp']);
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string, attempts_left?: int}
+     */
+    public function verifyOtp(string $email, string $code): array
+    {
+        if (!$this->required) {
+            $email = $this->normalizeEmail($email);
+            if ($email !== '') {
+                $now = time();
+                $state =& $this->state();
+                $state['verified_email'] = $email;
+                $state['verified_at'] = $now;
+                $state['last_activity_at'] = $now;
+            }
+
+            return ['ok' => true];
+        }
+
+        $email = $this->normalizeEmail($email);
+        $code = trim($code);
+        if ($email === '' || !preg_match('/^\d{6}$/', $code)) {
+            return [
+                'ok' => false,
+                'reason' => 'invalid_code',
+                'status_code' => 422,
+                'message' => 'Bitte einen gültigen 6-stelligen Code eingeben.',
+            ];
+        }
+
+        $pending = $this->pendingState();
+        $pendingEmail = $this->normalizeEmail((string) ($pending['email'] ?? ''));
+        if ($pending === [] || $pendingEmail === '' || !hash_equals($pendingEmail, $email)) {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst einen neuen Code anfordern.',
+            ];
+        }
+
+        $now = time();
+        $expiresAt = (int) ($pending['expires_at'] ?? 0);
+        if ($expiresAt <= 0 || $expiresAt < $now) {
+            $this->clearPendingOtp();
+            return [
+                'ok' => false,
+                'reason' => 'expired',
+                'status_code' => 401,
+                'message' => 'Der Code ist abgelaufen. Bitte neuen Code anfordern.',
+            ];
+        }
+
+        $expectedHash = (string) ($pending['hash'] ?? '');
+        $actualHash = $this->hashOtp($email, $code);
+        if ($expectedHash === '' || !hash_equals($expectedHash, $actualHash)) {
+            $state =& $this->state();
+            $attempts = (int) ($pending['attempts'] ?? 0) + 1;
+            $attemptsLeft = $this->otpAttemptLimit - $attempts;
+
+            if ($attemptsLeft <= 0) {
+                unset($state['pending_otp']);
+                return [
+                    'ok' => false,
+                    'reason' => 'attempt_limit',
+                    'status_code' => 401,
+                    'message' => 'Zu viele falsche Versuche. Bitte neuen Code anfordern.',
+                    'attempts_left' => 0,
+                ];
+            }
+
+            $pending['attempts'] = $attempts;
+            $state['pending_otp'] = $pending;
+
+            return [
+                'ok' => false,
+                'reason' => 'invalid_code',
+                'status_code' => 401,
+                'message' => 'Der Code ist ungültig.',
+                'attempts_left' => $attemptsLeft,
+            ];
+        }
+
+        $state =& $this->state();
+        unset($state['pending_otp']);
+        $state['verified_email'] = $email;
+        $state['verified_at'] = $now;
+        $state['last_activity_at'] = $now;
+
+        return ['ok' => true];
+    }
+
+    public function hasAutoOtpSentForEmail(string $email): bool
+    {
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return false;
+        }
+
+        $state = $this->state();
+        $marker = $state['auto_otp_sent'] ?? [];
+        if (!is_array($marker)) {
+            return false;
+        }
+
+        return isset($marker[$email]) && is_int($marker[$email]);
+    }
+
+    public function markAutoOtpSentForEmail(string $email): void
+    {
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return;
+        }
+
+        $state =& $this->state();
+        if (!isset($state['auto_otp_sent']) || !is_array($state['auto_otp_sent'])) {
+            $state['auto_otp_sent'] = [];
+        }
+
+        $state['auto_otp_sent'][$email] = time();
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string}
+     */
+    public function assertVerifiedForEmail(string $email, ?int $clientLastUserActivityAt = null): array
+    {
+        if (!$this->required) {
+            return ['ok' => true];
+        }
+
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst E-Mail und Code bestätigen.',
+            ];
+        }
+
+        $state =& $this->state();
+        $verifiedEmail = $this->normalizeEmail((string) ($state['verified_email'] ?? ''));
+        if ($verifiedEmail === '' || !hash_equals($verifiedEmail, $email)) {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst E-Mail und Code bestätigen.',
+            ];
+        }
+
+        $now = time();
+        $verifiedAt = (int) ($state['verified_at'] ?? 0);
+        $lastActivityAt = (int) ($state['last_activity_at'] ?? 0);
+        if ($lastActivityAt <= 0) {
+            $lastActivityAt = $verifiedAt > 0 ? $verifiedAt : $now;
+        }
+
+        $clientActivity = $this->sanitizeClientActivityTimestamp($clientLastUserActivityAt, $now);
+        $effectiveLastActivity = $lastActivityAt;
+        if ($clientActivity !== null && $clientActivity > $effectiveLastActivity) {
+            $effectiveLastActivity = $clientActivity;
+        }
+
+        if (($now - $effectiveLastActivity) > $this->inactivitySeconds) {
+            $this->clearVerifiedState();
+            return [
+                'ok' => false,
+                'reason' => 'auth_expired',
+                'status_code' => 401,
+                'message' => 'Sitzung abgelaufen. Bitte E-Mail erneut verifizieren.',
+            ];
+        }
+
+        $state['last_activity_at'] = $effectiveLastActivity;
+
+        return ['ok' => true];
+    }
+
+    public function clearVerifiedState(): void
+    {
+        $state =& $this->state();
+        unset($state['verified_email'], $state['verified_at'], $state['last_activity_at']);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function pendingState(): array
+    {
+        $state = $this->state();
+        $pending = $state['pending_otp'] ?? [];
+        return is_array($pending) ? $pending : [];
+    }
+
+    private function normalizeEmail(string $email): string
+    {
+        return strtolower(trim($email));
+    }
+
+    private function hashOtp(string $email, string $code): string
+    {
+        $secret = (string) ($_SESSION['_csrf_token'] ?? session_id());
+        return hash_hmac('sha256', $this->normalizeEmail($email) . '|' . $code, $secret);
+    }
+
+    private function sanitizeClientActivityTimestamp(?int $ts, int $now): ?int
+    {
+        if ($ts === null || $ts <= 0) {
+            return null;
+        }
+
+        if ($ts > ($now + 120)) {
+            return null;
+        }
+
+        if ($ts < ($now - 86400 * 7)) {
+            return null;
+        }
+
+        return $ts;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function &state(): array
+    {
+        if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
+            $_SESSION[self::SESSION_KEY] = [];
+        }
+
+        /** @var array<string, mixed> $state */
+        $state =& $_SESSION[self::SESSION_KEY];
+        return $state;
+    }
+}

+ 0 - 73
src/security/ratelimiter.php

@@ -1,73 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Security;
-
-use App\App\Bootstrap;
-
-final class RateLimiter
-{
-    private string $storageDir;
-    private bool $enabled;
-
-    public function __construct()
-    {
-        $app = Bootstrap::config('app');
-        $this->enabled = (bool) ($app['rate_limit']['enabled'] ?? true);
-        $this->storageDir = (string) ($app['storage']['rate_limit'] ?? Bootstrap::rootPath() . '/storage/rate_limit');
-
-        if ($this->enabled && !is_dir($this->storageDir)) {
-            mkdir($this->storageDir, 0775, true);
-        }
-    }
-
-    public function allow(string $key, int $limit, int $windowSeconds): bool
-    {
-        if (!$this->enabled) {
-            return true;
-        }
-
-        $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);
-        }
-    }
-}

+ 1 - 0
src/storage/jsonstore.php

@@ -126,6 +126,7 @@ final class JsonStore
             'submitted_at' => $now,
             'expires_at' => $expires,
             'step' => $submission['step'] ?? ($draft['step'] ?? null),
+            'is_minor_submission' => (bool) ($submission['is_minor_submission'] ?? false),
             'form_data' => (array) ($submission['form_data'] ?? []),
             'uploads' => (array) ($submission['uploads'] ?? []),
         ];