Files
tsv08kulmbach-website/app/actions/contact-submit.php
fs 17acfe7081 Frauenmannschaft statt Damen + zweite Mannschaft vorerst aus dem Matchcenter
Umbenennung durchgängig: „Damenmannschaft" → „Frauenmannschaft" und
„Damenfußball" → „Frauenfußball". Der Abteilungsname ist ein Zuordnungs-
schlüssel und musste auf allen Seiten gleichzeitig wandern — teams.json
(department + contact_department), ansprechpartner.json, die Abteilungslisten
auf Start- und Fußballseite, die Auswahl im Kontaktformular UND die Whitelist
in contact-submit.php. Geprüft: Ansprechpartner erscheinen weiter auf allen
vier Seiten, Formular-Optionen und serverseitige Whitelist sind deckungsgleich.

URL /fussball/damen und der Datenschlüssel „damen" bleiben absichtlich — eine
Slug-Änderung würde bestehende Links und die Suchmaschinen-Historie brechen,
sichtbar ist der Slug nirgends.

Die zweite Mannschaft ist in matchcenter.teams auskommentiert (nicht gelöscht):
Die BFV-Mannschaft hinter der ID lief 25/26 als „(SG1) TSV Melkendorf I/
TSV 08 Kulmbach II" und liefert in 26/27 nur noch „TSV Melkendorf" — also
Spiele ohne den TSV 08, samt Tabelle, die Melkendorf als eigenen Verein
markiert. Begründung steht bei der Zeile; nach Klärung im Verein reicht ein
Entfernen des Kommentars, ggf. mit neuer team_id. Die Seite
/fussball/zweite-mannschaft und ihr Menüpunkt bleiben unberührt, sie hängen
nicht am Matchcenter. Die Matchcenter-Description nannte die Zweite noch und
ist entsprechend neu gefasst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:41:02 +02:00

131 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…)
* oder JSON bei fetch (form.js). Das Formular kommt von / wie auch von /kontakt.
*/
use PHPMailer\PHPMailer\PHPMailer;
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
// Rücksprungziel gegen die Slug-Whitelist (Single Source of Truth) prüfen — kein Open-Redirect.
$return = $field('return');
$routes = require APP_PATH . '/routes.php';
if (!array_key_exists($return, $routes)) {
$return = '';
}
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson, $return): never {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'error' => $error ?: null]);
} else {
header('Location: ' . url($return) . ($ok ? '?sent=1' : '?error=' . $error) . '#kontakt', true, 303);
}
exit;
};
$route = 'kontakt';
$token = $field('ft');
// --- 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);
}
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
$name = $field('name');
$email = $field('email');
$phone = $field('phone');
$interest = $field('interest');
$subject = $field('subject');
$message = $field('message');
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$valid = $name !== '' && mb_strlen($name) <= 200
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& mb_strlen($phone) <= 50
&& in_array($interest, $interests, true)
&& $subject !== '' && mb_strlen($subject) <= 200
&& $message !== '' && mb_strlen($message) <= 5000
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $name . $subject);
if (!$valid) {
$respond(false, 'validation');
}
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) {
log_spam($route, 'links');
$respond(true);
}
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) {
log_spam($route, 'daily-cap');
$respond(true);
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
log_spam($route, 'replay');
$respond(true);
}
// --- Versand ---
$smtp = config('smtp');
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Name: {$name}\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. "Betreff: {$subject}\n"
. str_repeat('-', 40) . "\n\n"
. $message . "\n\n"
. str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $name);
$mail->Subject = 'Kontaktanfrage: ' . $subject;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
$respond(true);