Files
tsv08kulmbach-website/app/helpers.php

327 lines
10 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* HTML-Escaping für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') '/fussball', url('') '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
*/
function asset(string $path): string
{
$path = ltrim($path, '/');
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.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('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}
/**
* JSON-Datendatei laden (data/<name>.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;
}
Matchcenter: Spielplan, Ergebnisse & Tabellen (eigene Seite statt BFV-Widget) Neue Unterseite /matchcenter ersetzt die alten BFV-Widget-Embeds (verstoßen gegen CSP) durch eine self-hostete Lösung: Cron-Scraper holt die Daten aus der öffentlichen BFV-Widget-JSON-API, schreibt data/matchcenter.json und lädt die Vereinswappen lokal — Besucher kontaktieren nur unsere Domain. - bin/matchcenter-sync.php: CLI/Cron-Scraper (Muster wie instagram-sync), JSON-API team/{id}/matches + competition/{compoundId}/table, Wappen via getLogo lokal, atomarer Write, Fail-safe. Auto-Saison: Tabellen-compoundId wird aus der Matches-API abgeleitet (team_id ist saison-stabil). Per-Team-Cache-Fallback bei Fetch-Fehlern, damit Sektionen nie leeren. - Seite app/pages/matchcenter.php + Route + Nav-Eintrag (unter Fußball). - Komponenten: match-slider (nächste Spiele), match-card, match-row, league-table (mit Auf-/Abstiegszonen), matchcenter-section (kicker-Layout: Tabelle 2/3 + Spiele 1/3), matchcenter-empty, crest (weißes Chip + Initialen-Fallback). - carousel.js: banner.js zum generischen [data-carousel]-Antrieb verallgemeinert (Banner- und Match-Slider teilen ihn); crest.js: Logo-Fallback im Browser. - helpers.php: sportsevent_nodes() für SportsEvent-JSON-LD. - Icons calendar-event; config.example + .gitignore + CLAUDE.md ergänzt. - Responsive geprüft (kein Seiten-Overflow 360–1280, Tabelle scrollt in der Karte). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0192vTfGmGpoWeyUse4TuQFj
2026-06-19 10:57:14 +02:00
/**
* 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;
}