diff --git a/CLAUDE.md b/CLAUDE.md index e26acb5..016b164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,9 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern. - **Frontend:** Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node. - **Backend:** Vanilla PHP ≥ 8.1, Composer nur für `phpmailer/phpmailer`. Keine Template-Engine. -- **Hosting-Ziel:** All-Inkl Shared Hosting, PHP 8.x, `.htaccess`, SSH **und** Shell-Cronjobs - (seit 31.07.2026 im Tarif). Deploy-Anleitung Schritt für Schritt: **`docs/deploy.md`**. +- **Hosting-Ziel:** All-Inkl Shared Hosting, PHP 8.x, `.htaccess`, SSH (seit 31.07.2026) und + Cronjobs, die aber **nur URLs aufrufen können** — deshalb läuft der Takt über + `public/cron.php`, siehe dort. Deploy-Anleitung Schritt für Schritt: **`docs/deploy.md`**. - **Lokales Dev:** `php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php` (index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). Die beiden `-d`-Schalter brauchst du für die Dateiablage `/dateien`: der CLI-Server liest keine `.user.ini`, und mit den @@ -227,7 +228,9 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht. Web wie CLI. Kein `error_log()`, kein `file_put_contents()` auf eine Logdatei, keine eigene Log-Closure in einem neuen Skript. - Kanal = Dateiname unter `storage/logs/`: `mail` · `spam` · `instagram` · `matchcenter` · - `stadionzeitung` · `veranstaltungen` + `stadionzeitung` · `veranstaltungen` · `chronik` · `filesgallery` · `logrotate` + (letzterer nur für den Übersprungen-Hinweis des Cron-Endpoints, der seinen Kanal aus dem + Job-Namen bildet) (`php-errors.log` schreibt PHP selbst, eigenes Format). - Level mit klarer Bedeutung: **INFO** Normalbetrieb (auch ein geblockter Bot — der Schutz arbeitet, das ist kein Fehler) · **WARN** Auffälligkeit, Betrieb läuft weiter (Einzelbild @@ -341,23 +344,29 @@ Keine rohen Werte in `base/layout/components/utilities.css` — es gibt für all - [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen - [ ] `php -l` sauber; Seite lokal geprüft -## Cron-Endpoint `public/cron.php` (Ausnahme 3b) — **stillgelegt** +## Cron-Endpoint `public/cron.php` (Ausnahme 3b) — **der Takt-Geber** -**Seit 31.07.2026 hat der Tarif SSH und echte Shell-Cronjobs.** Damit ist der Endpoint überflüssig: -er war ausschließlich der Ersatz für den fehlenden Cron. `cron.key` steht in der Produktiv-Config auf -`''`, der Endpoint antwortet also auf jeden Aufruf mit 403 — ein Geheimnis weniger, das bei einem -externen Dienst liegt und leaken kann. Der Takt läuft über die Crontab-Zeilen aus -`config/config.example.php` (Weg A), der Ablauf steht in **`docs/deploy.md`**, Teil D. +**Die KAS-Cronjobs können ausschließlich URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026: +das Anlege-Formular hat nur ein Feld „Protokoll / Pfad" mit `https://`). Der Tarif hat inzwischen SSH +und Cronjobs, aber ein `php bin/…` ist dort nicht eintragbar. Deshalb ruft der **KAS-Cron diesen +Endpoint** auf, und er startet das Skript serverseitig. Der Ablauf steht in **`docs/deploy.md`**, Teil D, +die drei Zeitpunkte in `config/config.example.php`. -**Der Code bleibt.** Er ist geprüft, kostet nichts (403 in drei Zeilen) und ist der Rückweg, falls der -Shell-Cron ausfällt: neuen Schlüssel in `cron.key`, fertig. Deshalb sind die Grenzen unten weiter -beschrieben, statt sie mit dem Code zu löschen — wer ihn je wieder aufschließt, braucht sie. -`bin/preflight.php` warnt bei leerem Schlüssel und weist damit auf genau diese Entscheidung hin; -die Warnung ist erwartet, kein Mangel. +Das ist besser als der ursprünglich geplante externe Cron-Dienst: **der Aufrufer ist der Hoster +selbst**, der Schlüssel verlässt den Webspace nie, es ist überhaupt kein Dritter beteiligt. +`cron.key` muss also gesetzt sein — steht er leer, läuft **kein** Sync mehr. -Die Begründung von damals, weiterhin gültig für den Fall der Reaktivierung: ein externer Cron-Dienst ruft -`https://…/cron.php?job=matchcenter` bzw. `?job=instagram` auf, der Endpoint startet das Skript -serverseitig. Schlüssel aus `config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter. +Ein externer Cron-Dienst ruft, falls je nötig, dieselben URLs auf: +`https://…/cron.php?job=matchcenter` · `?job=instagram` · `?job=logrotate`. Schlüssel aus +`config('cron.key')`, per Header `X-Cron-Key` **oder** als `key`-Parameter (im KAS notgedrungen als +Parameter, das Formular kennt keine Header). + +**Warum es drei Jobs sind:** `logrotate` kam am 31.07.2026 dazu, weil dieser Endpoint der einzige Weg +ist, überhaupt etwas getaktet laufen zu lassen. Ohne ihn liefe die Logpflege nie automatisch und die +2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Der Job passt in denselben Rahmen wie die Syncs: kein +Parameter aus der URL, keine Verbindung nach außen, er schreibt nur in `storage/logs`. Eine **vierte** +Ausnahme braucht wieder denselben Aufwand an Begründung. `bin/log-rotate.php` trägt dafür denselben +Riegel wie die Syncs (`PHP_SAPI !== 'cli' && !defined('CRON_HTTP')`) und `require_once`. **Warum dieser Weg und nicht GitHub Actions mit FTP-Upload:** dort müsste ein Dritter Schreibzugang zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden diff --git a/bin/log-rotate.php b/bin/log-rotate.php index 63dbed5..8287025 100644 --- a/bin/log-rotate.php +++ b/bin/log-rotate.php @@ -16,11 +16,16 @@ declare(strict_types=1); * * Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v] */ -if (PHP_SAPI !== 'cli') { +// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP +// definiert. Nötig, weil die KAS-Cronjobs ausschließlich URLs aufrufen können und +// dieses Skript sonst nie automatisch liefe (Stand 31.07.2026). +if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) { exit(1); } -require dirname(__DIR__) . '/app/bootstrap.php'; +// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php +// definiert Konstanten, ein zweiter Durchlauf wäre ein Fatal. +require_once dirname(__DIR__) . '/app/bootstrap.php'; $maxMb = 2.0; $keepDays = 90; diff --git a/config/config.example.php b/config/config.example.php index 42a9257..e14c3e1 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -7,57 +7,49 @@ * config.php ist gitignored und liegt außerhalb des Webroots (zusätzlich per .htaccess gesperrt). * * --------------------------------------------------------------------------- - * TAKT DER SYNC-SKRIPTE — zwei Wege, je nach Tarif + * TAKT DER SYNC-SKRIPTE — über public/cron.php, aufgerufen vom KAS-Cron * --------------------------------------------------------------------------- - * A) Der Tarif hat Cronjobs → Shell-Cronjobs unten verwenden. Sauberster Weg, - * kein Endpoint, kein Dritter im Spiel. - * ►►► DAS IST SEIT 31.07.2026 UNSER WEG: der Tarif hat SSH und Shell-Cronjobs. - * Deshalb steht `cron.key` unten leer und public/cron.php ist stillgelegt. - * B) Der Tarif hat KEINE Cronjobs (so war es bis 30.07.2026, das KAS meldete - * „in deinem Tarif nicht verfügbar“) → public/cron.php + externer Cron-Dienst. - * Nur noch als Rückweg dokumentiert, falls der Shell-Cron ausfällt. - * Dann `cron.key` unten setzen und beim Dienst diese URLs im gewünschten Takt - * aufrufen lassen (Schlüssel besser als Header X-Cron-Key als in der URL): + * Geprüft im KAS am 31.07.2026: der Tarif hat SSH und Cronjobs, aber die Cronjobs + * können ausschließlich **URLs** aufrufen. Ein `php bin/…` ist dort nicht eintragbar + * (das Formular hat nur ein Feld „Protokoll / Pfad" mit https://). Also läuft der Takt + * über public/cron.php, und der Aufrufer ist der Hoster selbst — kein Dritter, und der + * Schlüssel verlässt den Webspace nie. * - * https://www.tsv08kulmbach.de/cron.php?job=matchcenter - * https://www.tsv08kulmbach.de/cron.php?job=instagram + * URLs (Schlüssel = cron.key unten): + * https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key= + * https://www.tsv08kulmbach.de/cron.php?job=instagram&key= + * https://www.tsv08kulmbach.de/cron.php?job=logrotate&key= * - * Takt wie unten bei den Cronjobs. Der Dienst muss keine Antwort auswerten: - * 204 = angenommen oder wegen Mindestabstand übersprungen, 403 = Schlüssel oder - * Job falsch (dann ist der Dienst falsch eingerichtet und es syncte nichts). - * Erwarteter Fehlerfall: Instagram sperrt Rechenzentrums-IPs. Einmal von Hand - * prüfen, ob der Scraper auf dem Server überhaupt durchkommt. + * Antwort ist immer leer: 204 = gelaufen oder wegen Mindestabstand übersprungen, + * 403 = Schlüssel oder Job falsch. Der KAS-Cron muss nichts auswerten. Das + * E-Mail-Feld im KAS kann leer bleiben; ob die Syncs laufen, sagt `php bin/logs.php`, + * und `php bin/preflight.php` warnt, wenn ein Datenstand zu alt wird. * * --------------------------------------------------------------------------- - * CRONJOBS (im KAS anlegen, Typ „eigener Cronjob“ / Shell) — nur Weg A + * DIE DREI CRONJOBS IM KAS (Zeitraster des KAS, nicht Crontab-Syntax) * --------------------------------------------------------------------------- - * SITE=/www/htdocs/w01ca75d/tsv08kulmbach-website (echter Pfad, ermittelt 31.07.2026) - * PHP=/usr/bin/php (8.3.29; mit `which php` gegenprüfen) + * 1. Matchcenter — „stündlich", Minute 37 + * Das KAS kann keine Wochentage mit Halbstunden-Takt kombinieren, wie es die + * ursprüngliche Crontab vorsah (Sa+So 13-21 Uhr halbstündlich). Stündlich rund um + * die Uhr ist der brauchbare Ersatz: am Spieltag stehen Ergebnis und Tabelle + * spätestens eine Stunde später. Kostet 24 Läufe/Tag statt 2 plus Wochenende. + * Wer den Verkehr zum BFV klein halten will, nimmt stattdessen zwei „täglich"-Jobs + * (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen. * - * Die Fehlerausgabe geht bewusst nach php-errors.log und NICHT in die Kanal-Logs: - * die Skripte schreiben ihren Verlauf selbst über log_write() im Format - * `[Zeit] LEVEL Meldung`, und bin/logs.php wertet Kanäle nach Level aus. Eine rohe - * PHP-Warnung dazwischen bricht das Format. php-errors.log ist die Datei für PHPs - * eigene Ausgabe, und bin/logs.php behandelt sie schon als "eigenes Format". - * Nach /dev/null darf nur log-rotate: ein Fatal dort ist harmlos, ein Fatal in einem - * Sync (etwa fehlendes vendor/ nach einem halben Deploy) soll auffallen. + * 2. Instagram — ZWEI Jobs „täglich", 06:17 und 18:17 + * Zwei Einträge, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht stündlich: + * Instagram sperrt auffällige Zugriffsmuster deutlich schneller als der BFV. * - * # Instagram-Feed: 2×/Tag (versetzte Minuten, damit die Läufe sich nicht überlappen) - * 17 6,18 * * * cd $SITE && $PHP bin/instagram-sync.php >> storage/logs/php-errors.log 2>&1 + * 3. Logpflege — „täglich", 03:23 (`?job=logrotate`) + * Rotiert erst über 2 MB und löscht Einträge älter als 90 Tage, ein täglicher Lauf + * ist deshalb meistens ein No-Op. Wöchentlich genügt auch. * - * # Matchcenter: 2×/Tag als Grundtakt - * 37 7,19 * * * cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/php-errors.log 2>&1 + * Die Minuten sind versetzt, damit sich zwei Läufe nicht überlappen (die Syncs sperren + * sich per Lock, sonst fiele einer aus). * - * # Matchcenter am Spieltag-Nachmittag/Abend ½-stündlich (Sa+So 13–21 Uhr), - * # damit Ergebnisse und Tabelle zeitnah stehen. - * 7,37 13-21 * * 6,0 cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/php-errors.log 2>&1 - * - * # Logpflege: sonntags nachts. Rotiert Dateien über 2 MB und löscht Einträge älter als 90 Tage. - * 23 3 * * 0 cd $SITE && $PHP bin/log-rotate.php > /dev/null 2>&1 - * - * Alle Skripte sind CLI-only; die Syncs sperren sich per Lock gegen Parallelläufe und lassen - * bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie. - * Erstlauf nach dem Deploy einmal von Hand anstoßen (füllt Cache + Bilder/Wappen). + * Alle Skripte sind CLI-only bzw. laufen über den authentifizierten Endpoint; die Syncs + * lassen bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf + * schadet nie. Erstlauf nach dem Deploy einmal per SSH anstoßen (füllt Cache + Bilder). * * --------------------------------------------------------------------------- * NACH DEM DEPLOY @@ -117,12 +109,12 @@ return [ 'to' => 'info@tsv08kulmbach.de', ], - // Cron-Endpoint public/cron.php — nur nötig, wenn der Tarif keine Cronjobs hat - // (Weg B oben). Leer oder 'CHANGE_ME' lassen heißt: der Endpoint ist zu und - // antwortet auf jeden Aufruf mit 403. + // Cron-Endpoint public/cron.php — der Takt-Geber (siehe oben). Leer oder + // 'CHANGE_ME' heißt: der Endpoint ist zu und antwortet auf jeden Aufruf mit 403, + // dann läuft KEIN Sync mehr. 'cron' => [ - // Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden — der Schlüssel - // reist bei einem externen Dienst mit und darf dort nichts anderes aufschließen. + // Langes Zufallsgeheimnis, NICHT app_secret wiederverwenden: dieser Schlüssel + // steht im KAS in einer Cronjob-URL und darf nichts anderes aufschließen. // Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";' 'key' => 'CHANGE_ME', // Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei diff --git a/docs/deploy.md b/docs/deploy.md index 4cd9503..0268c8c 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -255,35 +255,53 @@ führen (macOS NFD gegen Linux NFC). ## Teil D — Cronjobs -Erst jetzt, damit ab dem Umschalten schon frische Daten liegen. `SITE` und der -PHP-Pfad kommen aus B1/B2. +**Die KAS-Cronjobs können nur URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026: +das Formular hat ein Feld „Protokoll / Pfad" mit `https://`, kein Kommandofeld). Der +Takt läuft deshalb über `public/cron.php`. Aufrufer ist der Hoster selbst — der +Schlüssel verlässt den Webspace nie, kein Dritter ist beteiligt. -[KAS] → **Cronjobs** → eigener Cronjob (Shell), vier Einträge: +Voraussetzung: `cron.key` muss in `config/config.php` gesetzt sein (48 Zeichen, +nicht `app_secret`). Steht er leer, antwortet der Endpoint mit 403 und **nichts** synct. +`bin/preflight.php` prüft Länge und Verwechslung. -``` -# Instagram 2x/Tag -17 6,18 * * * cd SITE && PHP bin/instagram-sync.php >> storage/logs/instagram.log 2>&1 +[KAS] → **Cronjobs** → Anlegen, drei bis vier Einträge. `KEY` durch den Wert aus +`config.php` ersetzen: -# Matchcenter Grundtakt 2x/Tag -37 7,19 * * * cd SITE && PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1 +| # | Pfad | Zeitpunkt | +|---|---|---| +| 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 | +| 2 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 06:17 | +| 3 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 18:17 | +| 4 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 | -# Matchcenter am Spieltag halbstuendlich (Sa+So 13-21 Uhr) -7,37 13-21 * * 6,0 cd SITE && PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1 +Vier Dinge dahinter sind Absicht: -# Logpflege sonntags nachts -23 3 * * 0 cd SITE && PHP bin/log-rotate.php > /dev/null 2>&1 +- **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage + nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah. + Stündlich rund um die Uhr ist der brauchbare Ersatz: am Spieltag steht das Ergebnis + spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein + halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass + Samstagsergebnisse erst abends stehen. +- **Instagram zwei Einträge**, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht + stündlich: Instagram sperrt auffällige Zugriffsmuster schneller als der BFV. +- **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich + per Lock, sonst fällt einer aus). +- **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder + wegen Mindestabstand übersprungen, 403 = Schlüssel oder Job falsch), es gäbe also + nichts zu mailen. Ob der Takt läuft, sagt `php bin/logs.php`; `php bin/preflight.php` + warnt, wenn ein Datenstand zu alt wird. + +**Am Tag nach dem ersten Lauf** einmal `php bin/logs.php` ansehen. Läuft nichts, ist fast +immer der Schlüssel in der URL falsch oder abgeschnitten. Gegenprobe von Hand: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + 'https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY' ``` -`SITE` und `PHP` durch die echten absoluten Pfade ersetzen, auf diesem Webspace also -`cd /www/htdocs/w01ca75d/tsv08kulmbach-website && /usr/bin/php bin/…`. Die Minuten sind -versetzt, damit sich zwei Läufe nicht überlappen. Alle Skripte sind CLI-only, -sperren sich per Lock gegen Parallelläufe und lassen bei Fehlern den letzten guten -Cache stehen — ein fehlgeschlagener Lauf schadet nie. - -Am Tag nach dem ersten Cron-Lauf einmal `php bin/logs.php` ansehen. Läuft nichts, -ist fast immer der PHP-Pfad falsch oder relativ. - ---- +204 = angenommen, 403 = Schlüssel oder Job falsch. Direkt danach ein zweites Mal +aufgerufen muss es ebenfalls 204 geben — dann hat der Mindestabstand gegriffen und der +Lauf wurde übersprungen, was im Log als WARN steht. ## Teil E — Umschalten auf die Hauptdomain diff --git a/public/cron.php b/public/cron.php index e875544..8fb3975 100644 --- a/public/cron.php +++ b/public/cron.php @@ -5,14 +5,19 @@ declare(strict_types=1); /** * Cron-Endpoint — die ZWEITE benannte Ausnahme zu Regel 3 (siehe CLAUDE.md). * - * Warum es ihn gibt: der All-Inkl-Tarif kennt keine zeitgesteuerten Aufgaben, weder - * Shell- noch URL-Cronjobs ("in deinem Tarif nicht verfügbar"). Die Sync-Skripte müssen - * aber regelmäßig laufen, also muss der Takt von außen kommen. Ein externer Dienst - * (cron-job.org o. ä.) ruft diese Datei auf, sie startet das Skript serverseitig. + * 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. * - * Warum nicht der Weg über GitHub Actions mit FTP-Upload: dort müsste ein Dritter - * Schreibzugang zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der - * ausschließlich "starte einen der beiden Syncs" kann — deutlich kleinerer Radius. + * 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= * Schlüssel alternativ als Header X-Cron-Key (bevorzugt, landet nicht im Log @@ -37,9 +42,19 @@ 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'] ?? '');