$ok, 'error' => $error ?: null]); } else { header('Location: ' . url('mitglied-werden') . ($ok ? '?sent=1' : '?error=' . $error) . '#anmeldung-fussball', true, 303); } exit; }; $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? '')); $route = 'mitglied-werden'; $token = $field('ft'); // Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches // app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten. if (config_problems() !== []) { error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log'); $respond(false, 'mail'); } // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) --- if ($field('company_url') !== '') { log_spam($route, 'honeypot'); $respond(true); } if (!form_token_valid($token)) { log_spam($route, 'token'); $respond(true); } // --- Eingaben --- $firstName = $field('first_name'); $lastName = $field('last_name'); $birthDate = $field('birth_date'); $phone = $field('phone'); $street = $field('street'); $zip = $field('zip'); $city = $field('city'); $email = $field('email'); $feeClass = $field('fee_class'); $statute = ($_POST['statute'] ?? '') === '1'; $privacy = ($_POST['privacy'] ?? '') === '1'; // Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten. $data = json_load('mitglied-werden'); $feeOptions = array_map( static fn (array $r): string => $r['label'] . ' — ' . $r['price'], $data['fussball']['fees']['rows'] ?? [] ); // Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum. $birthValid = false; if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) { $birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1]) && $birthDate <= date('Y-m-d') && (int) $m[1] >= (int) date('Y') - 120; } // --- Validierung (Whitelist-Prinzip, Fehler pro Feld) --- $errors = []; if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) { $errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).'; } if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) { $errors['mm-last-name'] = 'Bitte gib den Nachnamen an (max. 100 Zeichen).'; } if (!$birthValid) { $errors['mm-birth-date'] = 'Bitte gib ein gültiges Geburtsdatum an.'; } if ($phone === '' || mb_strlen($phone) > 50) { $errors['mm-phone'] = 'Bitte gib eine Telefonnummer an (max. 50 Zeichen).'; } if ($street === '' || mb_strlen($street) > 200 || preg_match('/[\r\n]/', $street)) { $errors['mm-street'] = 'Bitte gib Straße und Hausnummer an.'; } if ($zip === '' || mb_strlen($zip) > 10 || preg_match('/[^\p{L}\d \-]/u', $zip)) { $errors['mm-zip'] = 'Bitte gib eine gültige Postleitzahl an.'; } if ($city === '' || mb_strlen($city) > 100 || preg_match('/[\r\n]/', $city)) { $errors['mm-city'] = 'Bitte gib den Wohnort an (max. 100 Zeichen).'; } if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { $errors['mm-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.'; } if (!in_array($feeClass, $feeOptions, true)) { $errors['mm-fee-class'] = 'Bitte wähle eine Beitragsklasse aus der Liste.'; } if (!$statute) { $errors['mm-statute'] = 'Ohne Anerkennung der Satzung ist keine Mitgliedschaft möglich.'; } if (!$privacy) { $errors['mm-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir den Antrag nicht entgegennehmen.'; } if ($errors !== []) { if ($wantsJson) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]); exit; } // Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten). render_form_invalid($route, [ 'first_name' => $firstName, 'last_name' => $lastName, 'birth_date' => $birthDate, 'phone' => $phone, 'street' => $street, 'zip' => $zip, 'city' => $city, 'email' => $email, 'fee_class' => $feeClass, 'statute' => $statute ? '1' : '', 'privacy' => $privacy ? '1' : '', ], $errors); } // --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen. // Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anträge. --- if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) { log_spam($route, 'ratelimit'); $respond(false, 'ratelimit'); } // Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token() // sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent. if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) { log_spam($route, 'replay'); $respond(false, 'ratelimit'); } // Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation. if (!rate_limit_ok('daily', 200, 86400)) { log_spam($route, 'daily-cap'); $respond(false, 'busy'); } // --- Versand --- $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n" . str_repeat('-', 40) . "\n" . "Vorname: {$firstName}\n" . "Nachname: {$lastName}\n" . "Geboren am: {$birthDate}\n" . "Adresse: {$street}, {$zip} {$city}\n" . "Telefon: {$phone}\n" . "E-Mail: {$email}\n" . "Beitragsklasse: {$feeClass}\n" . str_repeat('-', 40) . "\n" . "Satzung anerkannt: ja\n" . "Datenschutz: ja\n" . 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n" . "Hinweis: Bankdaten werden online nicht erhoben – Beitrittserklärung\n" . "per Post zur Unterschrift zusenden.\n"; // Mail 1 (intern) — erfolgskritisch. $sent = send_mail([ 'context' => 'Mitgliedsantrag', 'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName, 'body' => $body, 'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName], ]); if (!$sent) { $respond(false, 'mail'); } // Mail 2 (Eingangsbestätigung an den Antragsteller) — optional, Fehler nur ins Log. $welcome = $data['welcome_mail'] ?? null; if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) { send_mail([ 'context' => 'Bestätigungsmail Mitgliedsantrag', 'to' => $email, 'to_name' => $firstName . ' ' . $lastName, 'subject' => (string) $welcome['subject'], 'body' => (string) $welcome['body'], ]); } $respond(true);