Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
<?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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:11:58 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 00:27:19 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
|
|
|
|
|
|
* $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
|
|
|
|
|
|
* viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert —
|
|
|
|
|
|
* fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function img_intrinsic_attrs(string $rel): string
|
|
|
|
|
|
{
|
|
|
|
|
|
static $cache = [];
|
|
|
|
|
|
if (!isset($cache[$rel])) {
|
|
|
|
|
|
$file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
|
|
|
|
|
|
$w = $h = 0;
|
|
|
|
|
|
if (is_file($file)) {
|
|
|
|
|
|
if (str_ends_with(strtolower($file), '.svg')) {
|
|
|
|
|
|
$svg = (string) file_get_contents($file);
|
|
|
|
|
|
if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
|
|
|
|
|
|
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
|
|
|
|
|
|
} elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
|
|
|
|
|
|
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
[$w, $h] = (getimagesize($file) ?: [0, 0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return $cache[$rel];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider
Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte
eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte;
dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein
einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah.
Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür
vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante:
links der Aufmacher (match-feature), rechts die folgenden Termine als
verlinkte match-row-Zeilen ohne Kartenfläche.
Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast,
linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei
typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile
mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder
Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über
font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch.
match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen
jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist.
Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin,
weil PHP global auf UTC läuft) und match_competition_label().
match-slider.php und match-card.php entfallen — Letztere wurde
ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den
Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:39:57 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
|
|
|
|
|
|
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
|
|
|
|
|
|
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
|
|
|
|
|
|
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
|
|
|
|
|
|
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
|
|
|
|
|
|
* Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function match_when(string $kickoff): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($kickoff === '') {
|
|
|
|
|
|
return ['iso' => '', 'countdown' => ''];
|
|
|
|
|
|
}
|
|
|
|
|
|
$tz = new DateTimeZone('Europe/Berlin');
|
|
|
|
|
|
try {
|
|
|
|
|
|
$start = new DateTimeImmutable($kickoff, $tz);
|
|
|
|
|
|
} catch (Exception) {
|
|
|
|
|
|
return ['iso' => '', 'countdown' => ''];
|
|
|
|
|
|
}
|
|
|
|
|
|
$now = new DateTimeImmutable('now', $tz);
|
|
|
|
|
|
if ($start <= $now) {
|
|
|
|
|
|
return ['iso' => $start->format('c'), 'countdown' => ''];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
|
|
|
|
|
|
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
|
|
|
|
|
|
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
|
|
|
|
|
|
$time = $start->format('H:i') . ' Uhr';
|
|
|
|
|
|
return [
|
|
|
|
|
|
'iso' => $start->format('c'),
|
|
|
|
|
|
'countdown' => match (true) {
|
|
|
|
|
|
$days === 0 => 'heute, ' . $time,
|
|
|
|
|
|
$days === 1 => 'morgen, ' . $time,
|
|
|
|
|
|
$days < 14 => 'in ' . $days . ' Tagen',
|
|
|
|
|
|
default => 'in ' . (int) round($days / 7) . ' Wochen',
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
|
|
|
|
|
|
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
|
|
|
|
|
|
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
|
|
|
|
|
|
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
|
|
|
|
|
|
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
|
|
|
|
|
|
* Tabelle zählt.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function match_competition_label(string $competition, string $league = ''): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return match ($competition) {
|
|
|
|
|
|
'Liga' => $league,
|
|
|
|
|
|
'Freundschaft' => 'Freundschaftsspiel',
|
|
|
|
|
|
default => $competition,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-06-20 20:41:50 +02:00
|
|
|
|
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
|
|
|
|
|
|
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
*/
|
2026-06-20 20:41:50 +02:00
|
|
|
|
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
|
Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:25 +02:00
|
|
|
|
{
|
|
|
|
|
|
$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;
|
|
|
|
|
|
}
|
2026-06-19 08:12:05 +02:00
|
|
|
|
|
2026-06-20 20:41:50 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:12:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 23:17:53 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* WebSite-Knoten (für die Startseite): definiert die Site als Entität und
|
|
|
|
|
|
* verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
|
|
|
|
|
|
* keine Site-Suche.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function website_schema(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$club = json_load('club');
|
|
|
|
|
|
return [
|
|
|
|
|
|
'@type' => 'WebSite',
|
|
|
|
|
|
'@id' => abs_url() . '#website',
|
|
|
|
|
|
'url' => abs_url(),
|
|
|
|
|
|
'name' => $club['name'],
|
|
|
|
|
|
'inLanguage' => 'de-DE',
|
|
|
|
|
|
'publisher' => ['@id' => abs_url() . '#club'],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:12:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
2026-06-20 20:47:13 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
|
|
|
|
|
|
* data/mitmachen.json → positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
|
|
|
|
|
|
* employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
|
|
|
|
|
|
* jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
|
|
|
|
|
|
* optional — ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
|
|
|
|
|
|
* deshalb nur bei echt befristeten Stellen setzen. Ohne title → leerer Array (Aufrufer filtert).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function job_posting_schema(array $job, string $pageSlug): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (empty($job['title'])) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$club = json_load('club');
|
|
|
|
|
|
$node = [
|
|
|
|
|
|
'@type' => 'JobPosting',
|
|
|
|
|
|
'title' => $job['title'],
|
|
|
|
|
|
'description' => $job['description'] ?? ($job['summary'] ?? $job['title']),
|
|
|
|
|
|
'employmentType' => 'VOLUNTEER',
|
|
|
|
|
|
'hiringOrganization' => ['@id' => abs_url() . '#club'],
|
|
|
|
|
|
'jobLocation' => [
|
|
|
|
|
|
'@type' => 'Place',
|
|
|
|
|
|
'address' => [
|
|
|
|
|
|
'@type' => 'PostalAddress',
|
|
|
|
|
|
'streetAddress' => $club['address']['street'],
|
|
|
|
|
|
'postalCode' => $club['address']['zip'],
|
|
|
|
|
|
'addressLocality' => $club['address']['city'],
|
|
|
|
|
|
'addressCountry' => $club['address']['country'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
if (!empty($job['id'])) {
|
|
|
|
|
|
$node['identifier'] = [
|
|
|
|
|
|
'@type' => 'PropertyValue',
|
|
|
|
|
|
'name' => $club['name'],
|
|
|
|
|
|
'value' => $job['id'],
|
|
|
|
|
|
];
|
|
|
|
|
|
$node['url'] = abs_url($pageSlug) . '#' . $job['id'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!empty($job['posted'])) {
|
|
|
|
|
|
$node['datePosted'] = $job['posted'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!empty($job['valid_through'])) {
|
|
|
|
|
|
$node['validThrough'] = $job['valid_through'];
|
|
|
|
|
|
}
|
|
|
|
|
|
return $node;
|
|
|
|
|
|
}
|