Files
tsv08kulmbach-website/app/helpers.php
fs e7801ca6ee Spam-Schutz: Rate-Limiting, Link-Heuristik & Spam-Logging
Mehrschichtiger Formular-Schutz ohne externe Dienste, ergänzend zu Honeypot
und HMAC-Time-Trap:
- rate_limit_ok(): pro IP+Route (5/10 min), Tages-Cap (100/Tag), Token-Replay
- client_ip() / log_spam() (abgewiesene Versuche -> storage/logs/spam.log)
- Honeypot-Feld website -> company_url umbenannt (contact-/membership-form)
- Ratelimit-Hinweis in form.js
- storage/ratelimit/ (gitignored bis auf .gitkeep)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGzc6GhWhmLJt1jC2q1SRZ
2026-06-20 20:41:50 +02:00

327 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
/**
* 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;
}