Korrektur meiner eigenen Annahme von heute früh. Der Tarif hat SSH und Cronjobs, aber das KAS-Formular kennt nur ein Feld "Protokoll / Pfad" mit https:// — ein `php bin/…` ist dort nicht eintragbar. Der Endpoint, den ich vorhin stillgelegt hatte, ist damit wieder der einzige Takt-Geber, und zwar in einer besseren Form als geplant: Aufrufer ist der Hoster selbst, der Schlüssel verlässt den Webspace nie, kein Dritter ist beteiligt. Dritter Job 'logrotate' in der Whitelist, denn ohne ihn liefe die Logpflege nie automatisch und die 2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Er passt in denselben Rahmen wie die Syncs: kein Parameter aus der URL, keine Verbindung nach außen, schreibt nur in storage/logs. bin/log-rotate.php trägt dafür denselben Riegel wie die Syncs (CRON_HTTP) und require_once. Eine vierte Ausnahme braucht wieder denselben Aufwand an Begründung. Geprüft: falscher Schlüssel 403, unbekannter Job 403, gültiger Aufruf 204 mit leerem Body, sofortige Wiederholung 204 plus WARN, Logdateien unversehrt. Zeitraster angepasst, weil das KAS Wochentage nicht mit Halbstunden kombiniert: Matchcenter stündlich statt 2x/Tag plus Wochenende, Instagram als zwei Tageseinträge, Logpflege täglich nachts. Die Abwägung steht in config.example.php und docs/deploy.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
120 lines
5.3 KiB
PHP
120 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Cron-Endpoint — die ZWEITE benannte Ausnahme zu Regel 3 (siehe CLAUDE.md).
|
|
*
|
|
* Warum es ihn gibt (Stand 31.07.2026, geprüft im KAS): der Tarif hat inzwischen
|
|
* SSH und Cronjobs, aber die KAS-Cronjobs können ausschließlich **URLs** aufrufen,
|
|
* keine Shell-Befehle. Ein `php bin/…` als Cronjob ist dort nicht eintragbar. Die
|
|
* Skripte müssen aber getaktet laufen, also ruft der KAS-Cron diese Datei auf und
|
|
* sie startet das Skript serverseitig.
|
|
*
|
|
* Das ist besser als der ursprünglich geplante Weg mit einem externen Cron-Dienst:
|
|
* der Aufrufer ist jetzt der Hoster selbst, der Schlüssel verlässt den Webspace nie
|
|
* und es ist überhaupt kein Dritter beteiligt.
|
|
*
|
|
* Warum nicht GitHub Actions mit FTP-Upload: dort müsste ein Dritter Schreibzugang
|
|
* zum ganzen Webspace kennen. Hier kennt der Aufrufer einen Schlüssel, der
|
|
* ausschließlich "starte einen der drei Jobs" kann — deutlich kleinerer Radius.
|
|
*
|
|
* Aufruf: https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key>
|
|
* Schlüssel alternativ als Header X-Cron-Key (bevorzugt, landet nicht im Log
|
|
* des aufrufenden Dienstes) — die Datei akzeptiert beides.
|
|
*
|
|
* Grenzen, die den Endpoint harmlos halten:
|
|
* - Job-Whitelist, keine freien Skriptnamen aus der URL
|
|
* - Schlüssel aus config.php, Vergleich mit hash_equals; ohne echten Schlüssel ist zu
|
|
* - Mindestabstand zwischen zwei Läufen (cron.min_interval) — ein geleakter Link kann
|
|
* damit nicht dauerfeuern und uns die Server-IP bei BFV/Instagram sperren lassen
|
|
* - flock in den Skripten selbst: parallele Aufrufe überspringen sich
|
|
* - Antwort ist immer leer. 403 nur bei falschem Schlüssel/Job, damit ein falsch
|
|
* eingerichteter Cron-Dienst überhaupt auffällt (sonst syncte wochenlang nichts,
|
|
* ohne dass es jemand merkt). Der Schlüssel ist lang genug, dass das kein Orakel ist.
|
|
*
|
|
* Erreichbar ohne Rewrite-Anpassung: public/.htaccess leitet nur nicht-existierende
|
|
* Pfade auf index.php (RewriteCond %{REQUEST_FILENAME} !-f).
|
|
*/
|
|
|
|
require dirname(__DIR__) . '/app/bootstrap.php';
|
|
|
|
header('X-Robots-Tag: noindex, nofollow');
|
|
header('Cache-Control: no-store');
|
|
|
|
// Whitelist — nie ein Skriptname aus der URL.
|
|
//
|
|
// 'logrotate' kam am 31.07.2026 dazu, und das ist eine bewusste Erweiterung: weil die
|
|
// KAS-Cronjobs nur URLs aufrufen können, ist dieser Endpoint der EINZIGE Weg, überhaupt
|
|
// etwas getaktet laufen zu lassen. Ohne den Eintrag liefe die Logpflege nie automatisch,
|
|
// und die Logs wüchsen unbegrenzt (die 2-MB-Grenze und die 90-Tage-Frist wären toter
|
|
// Code). Der Job passt in denselben Rahmen wie die zwei Syncs: kein Parameter aus der
|
|
// URL, keine Netzverbindung nach außen, er schreibt ausschließlich in storage/logs.
|
|
// Eine VIERTE Ausnahme braucht wieder denselben Aufwand an Begründung.
|
|
$jobs = [
|
|
'matchcenter' => 'bin/matchcenter-sync.php',
|
|
'instagram' => 'bin/instagram-sync.php',
|
|
'logrotate' => 'bin/log-rotate.php',
|
|
];
|
|
|
|
$job = (string) ($_GET['job'] ?? '');
|
|
$key = (string) config('cron.key', '');
|
|
$given = (string) ($_SERVER['HTTP_X_CRON_KEY'] ?? $_GET['key'] ?? '');
|
|
|
|
$deny = static function (string $reason): never {
|
|
// Kein Detail in der Antwort, der Grund steht im Log. IP nur als gesalzener Hash.
|
|
log_spam('cron', $reason);
|
|
http_response_code(403);
|
|
exit;
|
|
};
|
|
|
|
// Not-Aus wie in den Formular-Actions: ohne echte config.php liefe die Seite auf der
|
|
// Vorlage, deren Werte öffentlich im Repo stehen — der Endpoint wäre dann offen.
|
|
if ($key === '' || $key === 'CHANGE_ME' || strlen($key) < 24) {
|
|
$deny('kein brauchbarer cron.key konfiguriert');
|
|
}
|
|
|
|
// Ein gemeinsamer Grund für beide Fälle: verrät nicht, ob Job oder Schlüssel falsch war.
|
|
if (!isset($jobs[$job]) || !hash_equals($key, $given)) {
|
|
$deny('job oder key ungueltig');
|
|
}
|
|
|
|
// --- Mindestabstand ---
|
|
// Marker wird bei JEDEM angenommenen Versuch gesetzt, nicht erst bei Erfolg: sonst
|
|
// dürfte ein dauerhaft fehlschlagender Sync beliebig oft neu angestoßen werden.
|
|
$minInterval = max(60, (int) config('cron.min_interval', 240));
|
|
$marker = STORAGE_PATH . '/cache/cron-' . $job . '.last';
|
|
clearstatcache(true, $marker);
|
|
if (is_file($marker) && filemtime($marker) > time() - $minInterval) {
|
|
log_write($job, 'WARN', "Cron-Trigger übersprungen — letzter Lauf vor weniger als {$minInterval} s.");
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
touch($marker);
|
|
|
|
// --- Lauf ---
|
|
// Status steht fest, BEVOR das Skript startet: es beendet sich per exit(), unser Code
|
|
// nach dem require läuft also nicht mehr zuverlässig.
|
|
http_response_code(204);
|
|
|
|
// Was die Skripte auf stdout schreiben (Debug-Ausgaben, PHP-Warnungen), darf nie im
|
|
// HTTP-Body landen. Der Shutdown-Handler greift auch bei exit() mitten im Skript.
|
|
ob_start();
|
|
register_shutdown_function(static function (): void {
|
|
while (ob_get_level() > 0) {
|
|
ob_end_clean();
|
|
}
|
|
});
|
|
|
|
set_time_limit(180); // Steady State ~2,5 s; Luft nur für den Erstlauf mit allen Bildern
|
|
ignore_user_abort(true); // Timeout beim aufrufenden Dienst darf den Sync nicht zerreißen
|
|
|
|
// Hebt den CLI-Riegel in den Sync-Skripten auf — nur hier, nach der Prüfung oben.
|
|
define('CRON_HTTP', true);
|
|
|
|
// Die Skripte lesen ihre Schalter aus $argv; im Web-SAPI existiert das nicht.
|
|
$argv = [$jobs[$job]];
|
|
$argc = 1;
|
|
|
|
require ROOT_PATH . '/' . $jobs[$job];
|