3 Commits f973d0e6ca ... a70a2915da

Auteur SHA1 Bericht Datum
  Medowar a70a2915da removing ratelimiting, only adding honeypot form as protection 1 week geleden
  Medowar 1273747603 updating ratelimit doc 1 week geleden
  Medowar d65cf215e3 adding form generation for minor-parent signature 1 week geleden

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

+ 0 - 11
api/delete-upload.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -107,16 +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' => Bootstrap::appMessage('delete_upload.rate_limited'),
-    ], 429);
-}
-
 $store = new JsonStore();
 
 try {

+ 0 - 12
api/load-draft.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -56,17 +55,6 @@ if (($auth['ok'] ?? false) !== true) {
     ], (int) ($auth['status_code'] ?? 401));
 }
 
-$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' => Bootstrap::appMessage('load_draft.rate_limited'),
-    ], 429);
-}
-
 $store = new JsonStore();
 $submission = $store->getSubmissionByEmail($email);
 if ($submission !== null) {

+ 0 - 9
api/request-otp.php

@@ -6,7 +6,6 @@ use App\App\Bootstrap;
 use App\Mail\Mailer;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -32,14 +31,6 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
 $autoStartRaw = strtolower(trim((string) ($_POST['auto_start'] ?? '0')));
 $autoStart = in_array($autoStartRaw, ['1', 'true', 'yes'], true);
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('otp-request:%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);
-}
-
 $formAccess = new FormAccess();
 $request = $formAccess->requestOtp($email, $autoStart);
 if (($request['ok'] ?? false) !== true) {

+ 0 - 10
api/reset.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -57,15 +56,6 @@ if (($auth['ok'] ?? false) !== true) {
 }
 
 $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' => Bootstrap::appMessage('reset.rate_limited'),
-    ], 429);
-}
 
 $store = new JsonStore();
 

+ 0 - 12
api/save-draft.php

@@ -6,7 +6,6 @@ use App\App\Bootstrap;
 use App\Form\FormSchema;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 
@@ -57,17 +56,6 @@ if (($auth['ok'] ?? false) !== true) {
     ], (int) ($auth['status_code'] ?? 401));
 }
 
-$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' => Bootstrap::appMessage('save_draft.rate_limited'),
-    ], 429);
-}
-
 $step = (int) ($_POST['step'] ?? 1);
 $formDataRaw = $_POST['form_data'] ?? [];
 $formData = [];

+ 21 - 10
api/submit.php

@@ -8,7 +8,6 @@ use App\Form\Validator;
 use App\Mail\Mailer;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 
@@ -30,6 +29,26 @@ 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,
@@ -75,15 +94,6 @@ if (($auth['ok'] ?? false) !== true) {
 }
 
 $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' => Bootstrap::appMessage('submit.rate_limited'),
-    ], 429);
-}
 
 $formDataRaw = $_POST['form_data'] ?? [];
 $formData = [];
@@ -158,6 +168,7 @@ try {
             'step' => 4,
             'form_data' => $mergedFormData,
             'uploads' => $mergedUploads,
+            'is_minor_submission' => isMinorBirthdate((string) ($mergedFormData['geburtsdatum'] ?? '')),
         ]);
 
         return [

+ 0 - 8
api/upload-preview.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -79,13 +78,6 @@ 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'])) {
-    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.rate_limited'), 429);
-}
-
 $store = new JsonStore();
 $draft = $store->getDraft($email);
 

+ 0 - 9
api/verify-otp.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -30,14 +29,6 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
 
 $code = trim((string) ($_POST['otp_code'] ?? ''));
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('otp-verify:%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);
-}
-
 $formAccess = new FormAccess();
 $result = $formAccess->verifyOtp($email, $code);
 if (($result['ok'] ?? false) !== true) {

+ 17 - 6
assets/js/form.js

@@ -52,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');
@@ -194,6 +196,15 @@
     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);
   }
@@ -1227,7 +1238,7 @@
     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));
@@ -1477,7 +1488,7 @@
   async function requestOtpCode(email, autoStart) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('email', email);
     fd.append('auto_start', autoStart ? '1' : '0');
     return postForm(appUrl('api/request-otp.php'), fd);
@@ -1486,7 +1497,7 @@
   async function verifyOtpCode(email, code) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('email', email);
     fd.append('otp_code', code);
     return postForm(appUrl('api/verify-otp.php'), fd);
@@ -1497,7 +1508,7 @@
     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) => {
@@ -1536,7 +1547,7 @@
     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);
   }
@@ -1545,7 +1556,7 @@
     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);
   }

+ 0 - 12
config/app.sample.php

@@ -33,11 +33,6 @@ return [
             'image/webp',
         ],
     ],
-    'rate_limit' => [
-        'enabled' => true,
-        'requests' => 30,
-        'window_seconds' => 300,
-    ],
     'verification' => [
         'required' => true,
         'inactivity_seconds' => 3600,
@@ -61,7 +56,6 @@ 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',
     ],
@@ -73,18 +67,15 @@ return [
             'invalid_email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
         ],
         'load_draft' => [
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
         ],
         'save_draft' => [
-            'rate_limited' => 'Ratelimited. Zu viele Speicheranfragen.',
             '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' => [
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             '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.',
@@ -93,14 +84,12 @@ return [
             'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
         ],
         'reset' => [
-            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
             '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.',
-            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
             '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.',
@@ -109,7 +98,6 @@ return [
         ],
         'upload_preview' => [
             'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             'draft_not_found' => 'Entwurf nicht gefunden.',
             'upload_not_found' => 'Upload nicht gefunden.',
             'file_not_found' => 'Datei nicht gefunden.',

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

+ 4 - 1
index.php

@@ -242,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 ?>">

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

+ 84 - 11
src/mail/mailer.php

@@ -61,10 +61,12 @@ final class Mailer
         $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);
@@ -72,6 +74,9 @@ final class Mailer
             if ($attachmentsPdf !== null) {
                 @unlink($attachmentsPdf);
             }
+            if ($minorSignaturePdf !== null) {
+                @unlink($minorSignaturePdf);
+            }
         }
     }
 
@@ -84,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 === '') {
@@ -123,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();
@@ -141,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());
             }
@@ -166,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);
@@ -175,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>';
@@ -203,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);
@@ -211,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";
@@ -237,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);
@@ -245,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>';
@@ -274,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";
@@ -321,6 +347,53 @@ 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'] ?? '');

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

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