Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon die Single Source of Truth, für Abstände aber nur mit --space-section und --container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39 verschiedenen rem-Werten gegenüber. Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5, 0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px) ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges. Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot, das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist. --container-pad und --space-section verweisen jetzt auf die Skala statt eigene Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide projektweit 0× verwendet. Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung noch nicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
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.
|
||||
* POST /kontakt-senden — Kontaktformular validieren und über All-Inkl-SMTP versenden.
|
||||
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…),
|
||||
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
|
||||
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
|
||||
* 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.
|
||||
@@ -34,6 +34,13 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson, $retu
|
||||
$route = 'kontakt';
|
||||
$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');
|
||||
@@ -44,54 +51,79 @@ if (!form_token_valid($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 ---
|
||||
// --- Eingaben ---
|
||||
$name = $field('name');
|
||||
$email = $field('email');
|
||||
$phone = $field('phone');
|
||||
$interest = $field('interest');
|
||||
$subject = $field('subject');
|
||||
$message = $field('message');
|
||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||
|
||||
$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');
|
||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
|
||||
$errors['contact-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
|
||||
}
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['contact-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if (mb_strlen($phone) > 50) {
|
||||
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
||||
}
|
||||
if (!in_array($interest, $interests, true)) {
|
||||
$errors['contact-interest'] = 'Bitte wähle einen der angebotenen Bereiche.';
|
||||
}
|
||||
if ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject)) {
|
||||
$errors['contact-subject'] = 'Bitte gib einen Betreff an (max. 200 Zeichen, einzeilig).';
|
||||
}
|
||||
if ($message === '' || mb_strlen($message) > 5000) {
|
||||
$errors['contact-message'] = 'Bitte schreib uns eine Nachricht (max. 5000 Zeichen).';
|
||||
}
|
||||
if (!$privacy) {
|
||||
$errors['contact-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir die Anfrage nicht entgegennehmen.';
|
||||
}
|
||||
|
||||
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
|
||||
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($return, [
|
||||
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
|
||||
'subject' => $subject, 'message' => $message, 'privacy' => $privacy ? '1' : '',
|
||||
], $errors);
|
||||
}
|
||||
|
||||
// --- Inhalts-Heuristik (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);
|
||||
|
||||
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
|
||||
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
|
||||
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
|
||||
// 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(true);
|
||||
$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 ---
|
||||
$smtp = config('smtp');
|
||||
|
||||
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. "Name: {$name}\n"
|
||||
@@ -104,27 +136,11 @@ $body = "Kontaktanfrage über tsv08kulmbach.de\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;
|
||||
$sent = send_mail([
|
||||
'context' => 'Kontaktanfrage',
|
||||
'subject' => 'Kontaktanfrage: ' . $subject,
|
||||
'body' => $body,
|
||||
'reply_to' => ['email' => $email, 'name' => $name],
|
||||
]);
|
||||
|
||||
$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);
|
||||
$respond($sent, $sent ? '' : 'mail');
|
||||
|
||||
Reference in New Issue
Block a user