.svg). * Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild * (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon. * Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen. */ function icon(string $name, string $class = '', ?string $label = null): string { static $cache = []; if (!array_key_exists($name, $cache)) { $file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg'; $cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : ''; } if ($cache[$name] === '') { return ''; } $attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"'; $attrs .= $label !== null && $label !== '' ? ' role="img" aria-label="' . e($label) . '"' : ' aria-hidden="true" focusable="false"'; return preg_replace('/.json) mit Request-weitem Cache. * Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise. */ function json_load(string $name): array { static $cache = []; if (!array_key_exists($name, $cache)) { $file = DATA_PATH . '/' . $name . '.json'; if (!is_file($file)) { return []; } $cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); } return $cache[$name]; } /** * Komponente rendern: component('hero', ['title' => …]). * Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes. */ function component(string $name, array $props = []): void { extract($props, EXTR_SKIP); require APP_PATH . '/components/' . $name . '.php'; } /** * Page-Datei ausführen: sie setzt $meta und emittiert ihren Body. * Rückgabe: [$meta, $html]. */ function render_page(string $file): array { $meta = []; ob_start(); require $file; return [$meta, (string) ob_get_clean()]; } /** * Signierten Zeitstempel für die Formular-Time-Trap erzeugen. */ function form_token(): string { $ts = (string) time(); return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret')); } /** * Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max. * Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht * grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab. */ function form_token_valid(string $token, int $min = 3, int $max = 86400): bool { $parts = explode('.', $token); if (count($parts) !== 2) { return false; } [$ts, $sig] = $parts; if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) { return false; } $age = time() - (int) $ts; return $age >= $min && $age <= $max; } /** * Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For * ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet. */ function client_ip(): string { return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); } /** * Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein * APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den * letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst * false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt * (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt. */ function rate_limit_ok(string $key, int $max, int $window): bool { $dir = STORAGE_PATH . '/ratelimit'; if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return true; } // Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen. if (random_int(1, 100) === 1) { foreach (glob($dir . '/*.json') ?: [] as $stale) { if ((int) @filemtime($stale) < time() - 86400) { @unlink($stale); } } } $file = $dir . '/' . hash('sha256', $key) . '.json'; $fh = @fopen($file, 'c+'); if ($fh === false) { return true; } try { flock($fh, LOCK_EX); $raw = (string) stream_get_contents($fh); $hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : []; $now = time(); $hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window)); if (count($hits) >= $max) { return false; } $hits[] = $now; rewind($fh); ftruncate($fh, 0); fwrite($fh, (string) json_encode($hits)); return true; } finally { flock($fh, LOCK_UN); fclose($fh); } } /** * Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine * Beobachtbarkeit zum Tunen der Schwellen. Datensparsam: nur ein gekürzter, * gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay. */ function log_spam(string $route, string $reason): void { $ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12); error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log'); } /** * BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad). */ function breadcrumb_schema(array $items): array { $list = []; foreach (array_values($items) as $i => $item) { $list[] = [ '@type' => 'ListItem', 'position' => $i + 1, 'name' => $item['name'], 'item' => abs_url($item['slug']), ]; } return ['@type' => 'BreadcrumbList', 'itemListElement' => $list]; } /** * FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen; * ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus). */ function faq_schema(array $faq): array { $questions = []; foreach ($faq as $item) { if (empty($item['q']) || empty($item['a'])) { continue; } $questions[] = [ '@type' => 'Question', 'name' => $item['q'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['a']], ]; } return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions]; } /** * Seiten-Schema-Knoten zusammenstellen (für $meta['schema']): * Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten. */ function page_schema(array $breadcrumb, array $faq = []): array { $nodes = []; if ($breadcrumb !== []) { $nodes[] = breadcrumb_schema($breadcrumb); } if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) { $nodes[] = $faqNode; } return $nodes; } /** * Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club * via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage. * $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug. */ function team_schema(array $team, string $slug): array { $teamNode = [ '@type' => 'SportsTeam', 'name' => $team['name'], 'sport' => 'Fußball', 'url' => abs_url($slug), 'memberOf' => ['@id' => abs_url() . '#club'], ]; if (!empty($team['hero']['text'])) { $teamNode['description'] = $team['hero']['text']; } $nodes = [$teamNode]; $nodes = array_merge($nodes, page_schema( [ ['name' => 'Startseite', 'slug' => ''], ['name' => 'Fußball', 'slug' => 'fussball'], ['name' => $team['name'], 'slug' => $slug], ], $team['faq'] ?? [] )); return $nodes; } /** * SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus * data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn * der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema']. */ function sportsevent_nodes(array $upcoming): array { $nodes = []; foreach ($upcoming as $m) { if (empty($m['home']) || empty($m['away'])) { continue; } $node = [ '@type' => 'SportsEvent', 'name' => $m['home'] . ' – ' . $m['away'], 'sport' => 'Fußball', 'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']], 'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']], 'eventStatus' => 'https://schema.org/EventScheduled', ]; if (!empty($m['kickoff'])) { $node['startDate'] = $m['kickoff']; } $nodes[] = $node; } return $nodes; }