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:
2026-07-27 10:07:31 +02:00
parent 96a698ebdc
commit 8304095dc3
6 changed files with 4736 additions and 140 deletions

4376
.filesgalleryplan/index.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden. * POST /kontakt-senden — Kontaktformular validieren und über All-Inkl-SMTP versenden.
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…) * 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. * 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] ?? '')); $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. // 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'; $route = 'kontakt';
$token = $field('ft'); $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) --- // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') { if ($field('company_url') !== '') {
log_spam($route, 'honeypot'); log_spam($route, 'honeypot');
@@ -44,54 +51,79 @@ if (!form_token_valid($token)) {
$respond(true); $respond(true);
} }
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen). // --- Eingaben ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
$name = $field('name'); $name = $field('name');
$email = $field('email'); $email = $field('email');
$phone = $field('phone'); $phone = $field('phone');
$interest = $field('interest'); $interest = $field('interest');
$subject = $field('subject'); $subject = $field('subject');
$message = $field('message'); $message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges']; $interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$valid = $name !== '' && mb_strlen($name) <= 200 // --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false $errors = [];
&& mb_strlen($phone) <= 50 if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
&& in_array($interest, $interests, true) $errors['contact-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
&& $subject !== '' && mb_strlen($subject) <= 200 }
&& $message !== '' && mb_strlen($message) <= 5000 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
&& ($_POST['privacy'] ?? '') === '1' $errors['contact-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
&& !preg_match('/[\r\n]/', $name . $subject); }
if (mb_strlen($phone) > 50) {
if (!$valid) { $errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
$respond(false, 'validation'); }
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) { if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) {
log_spam($route, 'links'); log_spam($route, 'links');
$respond(true); $respond(true);
} }
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) { // --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
log_spam($route, 'daily-cap'); // Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
$respond(true); 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). // Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
if (!rate_limit_ok('token|' . $token, 3, 86400)) { // 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'); 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 --- // --- Versand ---
$smtp = config('smtp');
$body = "Kontaktanfrage über tsv08kulmbach.de\n" $body = "Kontaktanfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. "Name: {$name}\n" . "Name: {$name}\n"
@@ -104,27 +136,11 @@ $body = "Kontaktanfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n"; . 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try { $sent = send_mail([
$mail = new PHPMailer(true); 'context' => 'Kontaktanfrage',
$mail->isSMTP(); 'subject' => 'Kontaktanfrage: ' . $subject,
$mail->Host = $smtp['host']; 'body' => $body,
$mail->Port = (int) $smtp['port']; 'reply_to' => ['email' => $email, 'name' => $name],
$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']); $respond($sent, $sent ? '' : 'mail');
$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);

View File

@@ -6,12 +6,11 @@ declare(strict_types=1);
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten). * POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung * Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt). * an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…) * Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…),
* oder JSON bei fetch (form.js). * JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
*/ */
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json'); $wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never { $respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
@@ -29,6 +28,13 @@ $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'mitglied-werden'; $route = 'mitglied-werden';
$token = $field('ft'); $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) --- // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') { if ($field('company_url') !== '') {
log_spam($route, 'honeypot'); log_spam($route, 'honeypot');
@@ -39,12 +45,6 @@ if (!form_token_valid($token)) {
$respond(true); $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');
}
// --- Eingaben --- // --- Eingaben ---
$firstName = $field('first_name'); $firstName = $field('first_name');
$lastName = $field('last_name'); $lastName = $field('last_name');
@@ -55,6 +55,8 @@ $zip = $field('zip');
$city = $field('city'); $city = $field('city');
$email = $field('email'); $email = $field('email');
$feeClass = $field('fee_class'); $feeClass = $field('fee_class');
$statute = ($_POST['statute'] ?? '') === '1';
$privacy = ($_POST['privacy'] ?? '') === '1';
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten. // Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
$data = json_load('mitglied-werden'); $data = json_load('mitglied-werden');
@@ -63,39 +65,84 @@ $feeOptions = array_map(
$data['fussball']['fees']['rows'] ?? [] $data['fussball']['fees']['rows'] ?? []
); );
// --- Validierung (Whitelist-Prinzip) --- // Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum.
$valid = $firstName !== '' && mb_strlen($firstName) <= 100 $birthValid = false;
&& $lastName !== '' && mb_strlen($lastName) <= 100 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) {
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1 $birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& $phone !== '' && mb_strlen($phone) <= 50 && $birthDate <= date('Y-m-d')
&& $street !== '' && mb_strlen($street) <= 200 && (int) $m[1] >= (int) date('Y') - 120;
&& $zip !== '' && mb_strlen($zip) <= 10
&& $city !== '' && mb_strlen($city) <= 100
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& in_array($feeClass, $feeOptions, true)
&& ($_POST['statute'] ?? '') === '1'
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street);
if (!$valid) {
$respond(false, 'validation');
} }
// --- Aufkommens-Heuristiken (still abweisen) --- // --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken. $errors = [];
if (!rate_limit_ok('daily', 100, 86400)) { if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) {
log_spam($route, 'daily-cap'); $errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).';
$respond(true);
} }
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden). if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) {
if (!rate_limit_ok('token|' . $token, 3, 86400)) { $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'); 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 --- // --- Versand ---
$smtp = config('smtp');
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n" $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. "Vorname: {$firstName}\n" . "Vorname: {$firstName}\n"
@@ -112,46 +159,28 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n" . "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n"
. "per Post zur Unterschrift zusenden.\n"; . "per Post zur Unterschrift zusenden.\n";
$buildMailer = static function () use ($smtp): PHPMailer {
$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;
return $mail;
};
// Mail 1 (intern) — erfolgskritisch. // Mail 1 (intern) — erfolgskritisch.
try { $sent = send_mail([
$mail = $buildMailer(); 'context' => 'Mitgliedsantrag',
$mail->setFrom($smtp['from'], $smtp['from_name']); 'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName,
$mail->addAddress($smtp['to']); 'body' => $body,
$mail->addReplyTo($email, $firstName . ' ' . $lastName); 'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName],
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName; ]);
$mail->Body = $body;
$mail->send(); if (!$sent) {
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail'); $respond(false, 'mail');
} }
// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken. // Mail 2 (Eingangsbestätigung an den Antragsteller) — optional, Fehler nur ins Log.
$welcome = $data['welcome_mail'] ?? null; $welcome = $data['welcome_mail'] ?? null;
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) { if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
try { send_mail([
$confirm = $buildMailer(); 'context' => 'Bestätigungsmail Mitgliedsantrag',
$confirm->setFrom($smtp['from'], $smtp['from_name']); 'to' => $email,
$confirm->addAddress($email, $firstName . ' ' . $lastName); 'to_name' => $firstName . ' ' . $lastName,
$confirm->Subject = $welcome['subject']; 'subject' => (string) $welcome['subject'],
$confirm->Body = $welcome['body']; 'body' => (string) $welcome['body'],
$confirm->send(); ]);
} catch (Throwable $e) {
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
}
} }
$respond(true); $respond(true);

View File

@@ -16,8 +16,10 @@ declare(strict_types=1);
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code * label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
* (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben! * (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben!
* *
* Jedes Feld bekommt ein leeres Fehler-Element (#<id>-error), das form.js bei * Jedes Feld bekommt ein Fehler-Element (#<id>-error, aria-describedby): serverseitig
* ungültiger Eingabe mit der Browser-Validierungsmeldung füllt (aria-describedby). * gefüllt beim 422-Re-Render (form_field_error()), sonst von form.js mit der
* Browser-Validierungsmeldung. Eingaben aus einem abgewiesenen POST schreibt
* form_old() zurück — deshalb gehen Werte auch ohne JS nie verloren.
*/ */
$type = $field['type'] ?? 'text'; $type = $field['type'] ?? 'text';
$name = $field['name']; $name = $field['name'];
@@ -26,6 +28,8 @@ $required = !empty($field['required']);
$errorId = $id . '-error'; $errorId = $id . '-error';
$hintId = $id . '-hint'; $hintId = $id . '-hint';
$describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId; $describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId;
$old = form_old($name);
$fieldError = form_field_error($id);
// Sichtbarer roter Stern (aria-hidden) + pro Feld erklärendes Pflicht-Wort für // Sichtbarer roter Stern (aria-hidden) + pro Feld erklärendes Pflicht-Wort für
// Screenreader — ersetzt die frühere globale „Pflichtfelder mit *"-Hinweiszeile. // Screenreader — ersetzt die frühere globale „Pflichtfelder mit *"-Hinweiszeile.
@@ -39,13 +43,14 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
. (!empty($field['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '') . (!empty($field['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '')
. (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '') . (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '')
. (isset($field['min']) ? ' min="' . e((string) $field['min']) . '"' : '') . (isset($field['min']) ? ' min="' . e((string) $field['min']) . '"' : '')
. (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : ''); . (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : '')
. ($fieldError !== '' ? ' aria-invalid="true"' : '');
?> ?>
<?php if ($type === 'checkbox'): ?> <?php if ($type === 'checkbox'): ?>
<div class="form__field form__field--checkbox"> <div class="form__field form__field--checkbox">
<input type="checkbox" <?= $commonAttrs ?> value="1"> <input type="checkbox" <?= $commonAttrs ?> value="1"<?= $old === '1' ? ' checked' : '' ?>>
<label for="<?= e($id) ?>"><?= $field['label_html'] ?? e($field['label']) ?><?= $requiredMark ?></label> <label for="<?= e($id) ?>"><?= $field['label_html'] ?? e($field['label']) ?><?= $requiredMark ?></label>
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p> <p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="form__field"> <div class="form__field">
@@ -54,17 +59,17 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
<select <?= $commonAttrs ?>> <select <?= $commonAttrs ?>>
<option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option> <option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option>
<?php foreach ($field['options'] as $option): ?> <?php foreach ($field['options'] as $option): ?>
<option value="<?= e($option) ?>"><?= e($option) ?></option> <option value="<?= e($option) ?>"<?= $old !== '' && $old === (string) $option ? ' selected' : '' ?>><?= e($option) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<?php elseif ($type === 'textarea'): ?> <?php elseif ($type === 'textarea'): ?>
<textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "></textarea> <textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "><?= e($old) ?></textarea>
<?php else: ?> <?php else: ?>
<input type="<?= e($type) ?>" <?= $commonAttrs ?> placeholder=" "> <input type="<?= e($type) ?>" <?= $commonAttrs ?> value="<?= e($old) ?>" placeholder=" ">
<?php endif; ?> <?php endif; ?>
<?php if (!empty($field['hint'])): ?> <?php if (!empty($field['hint'])): ?>
<p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p> <p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p>
<?php endif; ?> <?php endif; ?>
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p> <p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -20,6 +20,39 @@ function config(?string $key = null, mixed $default = null): mixed
return $value; return $value;
} }
/**
* Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
* unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
* als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
* auf config.example.php weiter — deren app_secret steht öffentlich im Repo, die
* Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
*/
function config_problems(): array
{
$problems = [];
if (!is_file(ROOT_PATH . '/config/config.php')) {
$problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
}
$secret = (string) config('app_secret');
if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
$problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
}
$smtp = (array) config('smtp', []);
foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
if (empty($smtp[$key])) {
$problems[] = "smtp.{$key} ist nicht gesetzt.";
}
}
if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
$problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
}
return $problems;
}
/** /**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden. * HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/ */
@@ -298,6 +331,113 @@ function log_spam(string $route, string $reason): void
error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log'); error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log');
} }
/**
* Formular-Mail über All-Inkl-SMTP verschicken — EINZIGE Versandstelle der Seite.
* Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
* an genau einem Ort stehen.
*
* $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
* reply_to ['email'=>…, 'name'=>…] · context (Präfix im Log).
*
* Gibt true/false zurück und wirft nie — Versandfehler landen in
* storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
* Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
* SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
*/
function send_mail(array $opts): bool
{
$smtp = (array) config('smtp', []);
$context = (string) ($opts['context'] ?? 'Mailversand');
try {
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = (string) $smtp['username'];
$mail->Password = (string) $smtp['password'];
$mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
$mail->Timeout = 10;
$mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)
$mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
$mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
if (!empty($opts['reply_to']['email'])) {
$mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
}
$mail->Subject = (string) $opts['subject'];
$mail->Body = (string) $opts['body'];
$mail->send();
return true;
} catch (Throwable $e) {
error_log('[' . date('c') . "] {$context} fehlgeschlagen: " . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
return false;
}
}
/**
* Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
* der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
* $old: Feldname → Wert. $errors: Feld-ID (wie im Markup) → Fehlermeldung.
*/
function form_state(?array $old = null, ?array $errors = null): array
{
static $state = ['old' => [], 'errors' => []];
if ($old !== null) {
$state = ['old' => $old, 'errors' => $errors ?? []];
}
return $state;
}
/**
* Zuvor eingegebener Wert eines Feldes ('' wenn keiner) — für value/selected/checked.
*/
function form_old(string $name): string
{
return (string) (form_state()['old'][$name] ?? '');
}
/**
* Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
*/
function form_field_error(string $id): string
{
return (string) (form_state()['errors'][$id] ?? '');
}
/**
* True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
*/
function form_has_errors(): bool
{
return form_state()['errors'] !== [];
}
/**
* Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
* Seite mit HTTP 422 direkt neu rendern — mit erhaltenen Eingaben und Feldfehlern.
* Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
* Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
* Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
*/
function render_form_invalid(string $slug, array $old, array $errors): never
{
form_state($old, $errors);
$routes = require APP_PATH . '/routes.php';
$current = isset($routes[$slug]) ? $slug : '';
http_response_code(422);
header('Cache-Control: no-store');
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';
exit;
}
/** /**
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad). * BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
*/ */

View File

@@ -11,15 +11,13 @@
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */ --clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
--clr-bg-deep: #161616; /* abgesetzte Sektionen */ --clr-bg-deep: #161616; /* abgesetzte Sektionen */
--clr-bg-footer: #000000; --clr-bg-footer: #000000;
--clr-bg-corporate: #383838; --clr-bg-corporate: #383838; /* Scrollbar-Thumb */
--clr-bg-light: #f5f5f5;
--clr-text: #ffffff; --clr-text: #ffffff;
--clr-text-muted: #b3b3b3; /* 7.7:1 auf --clr-bg */ --clr-text-muted: #b3b3b3; /* 7.7:1 auf --clr-bg */
--clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */ --clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */
--clr-sepia-bg: #f3efe4; /* Timeline-Museumsrahmen (Foto-Mount) */ --clr-sepia-bg: #f3efe4; /* Timeline-Museumsrahmen (Foto-Mount) */
--clr-sepia-text: #3a372f; /* Timeline-Text auf hellem Sepia-Grund */ --clr-sepia-text: #3a372f; /* Timeline-Text auf hellem Sepia-Grund */
--clr-glass-bg: rgb(0 0 0 / 0.22); /* dunkles Glas (dunkler als der Grund) */ --clr-glass-bg: rgb(0 0 0 / 0.22); /* dunkles Glas (dunkler als der Grund) */
--clr-surface-raised: rgb(255 255 255 / 0.055); /* Karte, die auf einem abgesetzten (dunkleren) Band liegt */
--clr-glass-border: rgb(255 255 255 / 0.14); /* sehr dezenter Card-Rand — Glow/Sheen tragen die Kontur */ --clr-glass-border: rgb(255 255 255 / 0.14); /* sehr dezenter Card-Rand — Glow/Sheen tragen die Kontur */
--clr-glass-border-strong: rgb(255 255 255 / 0.45); /* Back-to-Top, position-/group-card etc.: ≥3:1 (WCAG 1.4.11) */ --clr-glass-border-strong: rgb(255 255 255 / 0.45); /* Back-to-Top, position-/group-card etc.: ≥3:1 (WCAG 1.4.11) */
--clr-field-bg: rgb(255 255 255 / 0.07); /* Formularfelder: sanfte Füllfläche (etwas heller als das Glas) */ --clr-field-bg: rgb(255 255 255 / 0.07); /* Formularfelder: sanfte Füllfläche (etwas heller als das Glas) */
@@ -47,6 +45,28 @@
--fw-regular: 400; --fw-regular: 400;
--fw-bold: 700; --fw-bold: 700;
/* --- Spacing ---
4px-Raster. EINZIGE Quelle für padding/margin/gap — keine rohen rem-Werte
mehr in den anderen CSS-Dateien. Stufen wurden aus dem Ist-Bestand
geclustert, die häufigsten Werte treffen exakt (--space-s ist mit Abstand
der meistgenutzte). Nach oben wird das Raster gröber, weil große Abstände
das vertragen. --space-4xs (2px) ist ausschließlich für optische
Korrekturen gedacht (Icon-/Baseline-Nudges), nicht für Layout. */
--space-4xs: 0.125rem; /* 2px */
--space-3xs: 0.25rem; /* 4px */
--space-2xs: 0.5rem; /* 8px */
--space-xs: 0.75rem; /* 12px */
--space-s: 1rem; /* 16px */
--space-m: 1.25rem; /* 20px */
--space-l: 1.5rem; /* 24px */
--space-xl: 1.75rem; /* 28px */
--space-2xl: 2rem; /* 32px */
--space-3xl: 2.5rem; /* 40px */
--space-4xl: 3rem; /* 48px */
--space-5xl: 4rem; /* 64px */
--space-6xl: 5rem; /* 80px */
--space-7xl: 7rem; /* 112px */
/* --- Buttons --- */ /* --- Buttons --- */
--btn-font: 1rem; /* 16px Default (moderat) */ --btn-font: 1rem; /* 16px Default (moderat) */
--btn-font-sm: var(--fs-300); /* 13px dezente Buttons */ --btn-font-sm: var(--fs-300); /* 13px dezente Buttons */
@@ -55,6 +75,16 @@
--btn-pad-x: 1.25em; --btn-pad-x: 1.25em;
--btn-line: 1.2; --btn-line: 1.2;
/* --- Interaktion --- */
/* Mindest-Trefferfläche (WCAG 2.5.5 AAA). Controls, die optisch kleiner
bleiben sollen, strecken ihre Hit-Area per .tap-target auf diesen Wert —
gewertet wird die Trefferfläche, nicht die sichtbare Fläche. */
--tap-min: 44px;
/* Fokus immer weiß: 15,9:1 auf --clr-bg. Rot käme nur auf 3,2:1 und würde
WCAG 1.4.11 gerade so schaffen — als einziger Fokus-Indikator zu knapp. */
--focus-ring: 2px solid var(--clr-text);
--focus-offset: 3px;
/* --- Form & Tiefe --- */ /* --- Form & Tiefe --- */
--radius-btn: 8px; --radius-btn: 8px;
--radius-card: 15px; --radius-card: 15px;
@@ -67,8 +97,8 @@
/* --- Layout --- */ /* --- Layout --- */
--container-max: 1200px; --container-max: 1200px;
--container-pad: 1rem; --container-pad: var(--space-s);
--space-section: 5rem; --space-section: var(--space-6xl);
--header-h: 60px; --header-h: 60px;
--logo-h: 72px; --logo-h: 72px;