From 05ea7036551c6e4b2bb031ef11406887fe2e4c68 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 14 Sep 2026 08:30:13 +0200 Subject: [PATCH] Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen, geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend. Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht. Vor der Veroeffentlichung geprueft, nicht bloss eingepackt: - phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig. - Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an. - Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr. Dabei zwei Fehler behoben: - config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony, aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1 zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt. - AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von php-cs-fixer; composer lint gab 8 zurueck. Behoben. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- README.md | 21 +- VERSION | 2 +- config/services.yaml | 25 + docs/ARCHITECTURE.md | 35 +- docs/LIVEGANG.md | 2 +- docs/STATUS.md | 29 +- migrations/Version20260914000100.php | 66 +++ public/assets/admin.css | 5 + public/assets/task-worker.js | 45 ++ src/Command/RunTasksCommand.php | 98 ++++ src/Command/TasksCommand.php | 83 ++++ src/Controller/CronController.php | 60 +++ src/Controller/EditorController.php | 2 +- src/Controller/TaskController.php | 147 ++++++ src/EventSubscriber/TaskWorkerSubscriber.php | 84 ++++ src/Ops/ArchiveUpdate.php | 2 +- src/Ops/Scheduler.php | 498 +++++++++++++++++++ src/Ops/Tasks/BackupTask.php | 58 +++ src/Ops/Tasks/CleanupTask.php | 142 ++++++ src/Ops/Tasks/PruneStatisticsTask.php | 54 ++ src/Ops/Tasks/PublishDueTask.php | 48 ++ src/Ops/Tasks/Task.php | 44 ++ src/Ops/Tasks/UpdateCheckTask.php | 57 +++ templates/admin/base.html.twig | 3 + templates/admin/icon.html.twig | 1 + templates/admin/pages.html.twig | 2 +- templates/admin/shell.html.twig | 10 +- templates/admin/system.html.twig | 2 +- templates/admin/tasks.html.twig | 131 +++++ templates/install/done.html.twig | 2 +- tests/SchedulerTest.php | 259 ++++++++++ tests/SpyTask.php | 53 ++ tests/TaskScreenTest.php | 168 +++++++ tests/TestSchema.php | 2 + 35 files changed, 2229 insertions(+), 13 deletions(-) create mode 100644 migrations/Version20260914000100.php create mode 100644 public/assets/task-worker.js create mode 100644 src/Command/RunTasksCommand.php create mode 100644 src/Command/TasksCommand.php create mode 100644 src/Controller/CronController.php create mode 100644 src/Controller/TaskController.php create mode 100644 src/EventSubscriber/TaskWorkerSubscriber.php create mode 100644 src/Ops/Scheduler.php create mode 100644 src/Ops/Tasks/BackupTask.php create mode 100644 src/Ops/Tasks/CleanupTask.php create mode 100644 src/Ops/Tasks/PruneStatisticsTask.php create mode 100644 src/Ops/Tasks/PublishDueTask.php create mode 100644 src/Ops/Tasks/Task.php create mode 100644 src/Ops/Tasks/UpdateCheckTask.php create mode 100644 templates/admin/tasks.html.twig create mode 100644 tests/SchedulerTest.php create mode 100644 tests/SpyTask.php create mode 100644 tests/TaskScreenTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b77cf4e..efea11f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - run: php vendor/bin/doctrine-migrations migrate --no-interaction - run: php bin/console cms:seed-demo - run: php bin/console cms:seed-demo - - run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container + - run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container - run: composer build browser: runs-on: ubuntu-latest diff --git a/README.md b/README.md index f4ef856..5585840 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,25 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor. +## Wiederkehrende Aufgaben + +Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln. + +Vier Auslöser, in aufsteigender Verlässlichkeit: + +1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an. +2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an. +3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten. +4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute: + +``` +* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run +``` + +`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft. + +**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab. + ## PHP-Webhosting Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen: @@ -118,4 +137,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche **Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis. -Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore `. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. +Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore `. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. diff --git a/VERSION b/VERSION index 5ebba4f..ce6a70b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.5 \ No newline at end of file +1.6.0 \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 75e6be5..45073c1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -15,6 +15,12 @@ services: _defaults: autowire: true autoconfigure: true + # Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task + # implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine + # neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt. + _instanceof: + App\Ops\Tasks\Task: + tags: ['kant.task'] App\: resource: '../src/' exclude: '../src/{Kernel.php,Domain/}' @@ -86,6 +92,25 @@ services: $root: '%kernel.project_dir%' $databaseUrl: '%env(resolve:DATABASE_URL)%' $mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%' + # Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht + # jedes Mal mitwaechst. + App\Ops\Scheduler: + arguments: + $tasks: !tagged_iterator kant.task + $projectDir: '%kernel.project_dir%' + App\Ops\Tasks\CleanupTask: + arguments: + $projectDir: '%kernel.project_dir%' + App\EventSubscriber\TaskWorkerSubscriber: + arguments: + $projectDir: '%kernel.project_dir%' + $adminPath: '%kant.admin_path%' + App\Controller\TaskController: + arguments: + $secret: '%kernel.secret%' + App\Controller\CronController: + arguments: + $secret: '%kernel.secret%' App\Infrastructure\QueryCounter: ~ App\EventSubscriber\QueryHeaderSubscriber: arguments: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd5d82a..66e911b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -281,7 +281,7 @@ Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `re `revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln. -**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht. +**Geplante Veröffentlichung** setzt `publish_at`. Die Aufgabe `publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – bleibt die Seite ein Entwurf, die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht. **Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht. @@ -438,7 +438,38 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, **Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein. -**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht. +**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides; im Normalbetrieb erledigt das die Aufgabe `stats-prune` des Plans (ADR 073). + +## ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026) + +Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern **läuft nie**: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst. + +**Die Entscheidung: Was ein Hosting immer hat, sind Anfragen.** Also arbeitet eine Anfrage die Aufgaben ab – in `kernel.terminate`, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort `fastcgi_finish_request()` auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (`wp-cron`) und Shopware gehen, und aus demselben Grund. + +Vier Auslöser, eine Mechanik: + +| Auslöser | Wer stößt an | Grenzen je Durchlauf | +| --- | --- | --- | +| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s | +| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s | +| URL-Cron | der Hoster ruft `/cms-cron/` | 20 Aufgaben, 240 s | +| Konsole | `cms:tasks:run`, auch mit `--loop` | frei | + +**Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten:** Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht. + +Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt: + +**1. Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem `file_get_contents` fertig. Fehlt die Datei – frische Installation, geleertes `var/`, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes `var/` macht das System langsamer, aber nicht falsch. + +**2. Zwei Besucher führen eine Aufgabe nicht zweimal aus.** Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt)`. Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine **Tabelle** und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen. + +**3. Eine Aufgabe darf niemals die Website mitreißen.** Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen *kann*, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts. + +**Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist.** Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein `fastcgi_finish_request()` gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das. + +**Der Schlüssel des URL-Cronjobs steht in keiner Datenbank.** Er wird aus `APP_SECRET` abgeleitet (`hash_hmac`, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt. + +**Eine neue Aufgabe ist eine Datei.** Wer `App\Ops\Tasks\Task` implementiert, bekommt über `_instanceof` ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). ## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026) diff --git a/docs/LIVEGANG.md b/docs/LIVEGANG.md index 085075c..a949135 100644 --- a/docs/LIVEGANG.md +++ b/docs/LIVEGANG.md @@ -23,7 +23,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben 2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen. 3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet. 4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden. -5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten. +5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg. 6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll. 7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS. 8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind. diff --git a/docs/STATUS.md b/docs/STATUS.md index 9012134..65eb797 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: 14.09.2026, Fassung **1.5.5**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. +Stand: 14.09.2026, Fassung **1.6.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push. @@ -14,6 +14,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. **Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser. +**Wiederkehrende Aufgaben ohne Cronjob (14.09.2026, ADR 073):** Bis hierher stand in vier Dokumenten „gehört in einen Cronjob“ – auf geteiltem Hosting oft nicht einlösbar, und die Folge war nicht „läuft seltener“, sondern **läuft nie**. Jetzt gibt es einen Aufgabenplan mit vier Auslösern: **jeder Seitenaufruf** (nach der ausgelieferten Antwort, in `kernel.terminate`), das **geöffnete Backend** (im Minutentakt, nur solange der Reiter sichtbar ist), ein **URL-Cronjob** unter `/cms-cron/` für Hoster, die nur eine Adresse aufrufen können, und die **Konsole** (`cms:tasks:run`, auch als Dauerläufer mit `--loop`). Fünf Aufgaben sind angeschlossen: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). Der Bildschirm **Aufgaben** zeigt Plan, letztes Ergebnis, Dauer, Auslöser und Protokoll, schaltet jede Aufgabe einzeln und ändert ihren Abstand. **Die Einschränkung steht auf diesem Bildschirm:** ohne Besuch und ohne geöffnetes Backend passiert nichts – eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. + ## Erledigt - `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch. @@ -36,6 +38,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. +- **Aufgabenplan ohne Cronjob** (ADR 073, Migration `20260914000100`): eine Zeile je Aufgabe mit Fälligkeit, Sperre und letztem Ergebnis. Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE` – auf MySQL wie auf SQLite atomar, damit zwei gleichzeitige Besucher eine Aufgabe nicht zweimal starten; die Sperre verfällt nach 15 Minuten von selbst. Der Normalfall kostet **keine Datenbankabfrage**: in `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht. Ein Fehlschlag ist ein sichtbarer Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden), und eine scheiternde Aufgabe reißt nie eine Seite mit. Neue Aufgaben sind eine Datei: wer `App\Ops\Tasks\Task` implementiert, steht beim nächsten Abgleich im Plan. Befehle: `cms:tasks`, `cms:tasks:run` (`--task`, `--limit`, `--budget`, `--loop`, `--max-time`). - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. - **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (``, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern. - **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen. @@ -141,6 +144,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. - Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator. - Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab. +- **Aufgabenplan geprüft** (14.09.2026): 12 Tests auf die Mechanik (nur Fälliges läuft; eine gesperrte Aufgabe wird übergangen, eine abgelaufene Sperre übernommen; ein Fehlschlag wird festgehalten und der nächste Versuch rückt weiter weg; eine abgeschaltete Aufgabe läuft nicht, von Hand gestartet doch; eine Anfrage arbeitet nicht alles auf einmal ab; eine fehlende Tabelle ist kein Fehler) und 5 über HTTP (der Bildschirm nennt jede Aufgabe und die Adresse des URL-Cronjobs; anonym führt niemand etwas aus, eine Redakteurin bekommt 403, ohne gültiges Formular passiert nichts; der Takt des Backends antwortet mit JSON; ein falscher Cron-Schlüssel bekommt 404). Von Hand auf der Entwicklungsinstallation **alle vier Auslöser ausgeführt**: alle fünf Aufgaben über die Konsole (Sicherung 36 ms, Updateprüfung 318 ms gegen das echte Repository), ein Seitenaufruf hat eine fällige Aufgabe abgearbeitet (Auslöser `besuch` im Protokoll), das geöffnete Backend hat getaktet (`POST /admin/aufgaben/takt` → 200), und der URL-Cronjob hat mit dem richtigen Schlüssel Text geliefert und mit einem falschen 404. Der Bildschirm wurde im Browser bedient (Abstand und Schalter gespeichert und wieder zurückgesetzt); dabei fiel eine Inline-Stilangabe auf, die die CSP der Verwaltung blockiert hat – sie ist jetzt eine Klasse. - Ladeverhalten geprüft: drei Tests – 30 Fassungen ergeben zwei Seiten mit richtiger Reihenfolge und richtigem Rest, eine Seite jenseits des Endes zeigt die letzte statt einer leeren; das erste Bild trägt `loading="eager"` und `fetchpriority="high"`, das zweite `lazy`; und die Startseite bleibt unter zwölf Abfragen. Dazu das Ladebudget in der Browserprüfung. - **Wiederherstellung nachgewiesen**, nicht nur gebaut – das war der älteste offene Punkt aus Arbeitspaket I. Acht Tests gegen eine wegwerfbare Installation, darunter der eigentliche: Sicherung anlegen, Seite und Bilddatei löschen, zurückspielen, beides ist wieder da. Dazu von Hand auf der Entwicklungsinstallation: Testseite angelegt (5 Seiten), gesichert, gelöscht (4), zurückgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Ebenfalls geprüft: eine manipulierte Sicherung wird abgewiesen, eine aus einer neueren Fassung nur mit `--force`, Rotation behält die neuesten, und eine nach der Sicherung hochgeladene Datei überlebt die Wiederherstellung. - Seitenbereiche geprüft: Standard, anderer Bereich, „keiner“ (dann steht weder `site-header` noch `site-footer` im HTML, die Seite selbst aber schon), ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, eine veraltete Fassungsnummer und eine ungültige Adresse werden abgewiesen. Menüarten: drei angenommen, eine erfundene abgewiesen, Burger als `details` – und die Seite behält `script-src 'none'`. Seitenbreite: die Regel in `site.css` nimmt den Theme-Wert, und ein größerer Wert erscheint als `--k-container` im Seiten-Stylesheet. @@ -199,6 +203,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung. - Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen. - Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar. +- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server. - **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls. - **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): die Neugestaltung der Verwaltung. - Eigenes CSS kennt keine Gerätevarianten – dafür sind die Vorschaugrößen im Designbereich da. Eigenes Skript hat keine Fassungsverwaltung außer der Seitenhistorie und keine Prüfung auf Syntaxfehler; ein fehlerhaftes Skript schreibt in die Browserkonsole und lässt die Seite sonst unangetastet. @@ -475,6 +480,28 @@ die ausgelieferte `admin.css` schlägt der Test fehl. **Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452. +## 1.6.0: der Aufgabenplan wird veröffentlicht + +Diese Fassung bringt den **Aufgabenplan ohne Cronjob** (ADR 073, oben beschrieben) in ein +Release. Er entstand in einer parallel laufenden Sitzung und lag bis hierher unveröffentlicht im +Arbeitsverzeichnis – die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut +und enthielten ihn deshalb nicht. + +**Vor der Veröffentlichung geprüft, nicht bloß eingepackt:** + +- `php vendor/bin/phpunit` 452 von 452, `npm test` 28 von 28, `npm run test:editor` vollständig. +- Die Migration `20260914000100` legt **nur** die Tabelle `scheduled_task` an und nimmt sie beim + Zurückrollen wieder weg. Sie fasst keine bestehenden Daten an – das ist die risikoarme Sorte. +- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr; der Stand ist ruhig. + +**Dabei zwei Fehler behoben:** + +- `config/services.yaml` verwendet `!tagged_iterator`. Das ist gültiges Symfony, aber + `php bin/console lint:yaml config` kennt eigene Tags nur mit `--parse-tags` und gab 1 zurück – + der CI-Lauf in `.github/workflows/ci.yml` wäre daran gescheitert. Das Flag ist ergänzt. +- `src/EventSubscriber/AdminHeadersSubscriber.php` (aus 1.5.3) entsprach nicht dem Stil von + `php-cs-fixer`; `composer lint` gab 8 zurück. Behoben, jetzt 0 von 128 Dateien zu ändern. + ## Nächster konkreter Schritt Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit diff --git a/migrations/Version20260914000100.php b/migrations/Version20260914000100.php new file mode 100644 index 0000000..89c21c8 --- /dev/null +++ b/migrations/Version20260914000100.php @@ -0,0 +1,66 @@ +createTable('scheduled_task'); + // Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel: + // verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich. + $tasks->addColumn('name', 'string', ['length' => 64]); + // Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der + // Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation + // richtig und auf der naechsten zu selten ist. + $tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]); + $tasks->addColumn('enabled', 'boolean', ['default' => true]); + // Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt. + $tasks->addColumn('next_run_at', 'datetime_immutable'); + $tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]); + // Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer + // Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen + // hat, kann seine Sperre nicht mehr selbst aufraeumen. + $tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]); + $tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]); + $tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']); + $tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]); + // Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand. + // Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der + // Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt? + $tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']); + // Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten + // Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf + // wieder anfaengt. + $tasks->addColumn('failures', 'integer', ['default' => 0]); + $tasks->setPrimaryKey(['name']); + } + + public function down(Schema $schema): void + { + // Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt, + // kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an. + $schema->dropTable('scheduled_task'); + } +} diff --git a/public/assets/admin.css b/public/assets/admin.css index d802e81..4cdd21c 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -446,6 +446,11 @@ details.card > summary:hover { background: #fafbf8 } .theme-table td input[type=color] { width: 52px } .theme-table .weights-cell { white-space: nowrap } .redirect-table td { padding: 8px 12px 8px 0; font-size: 12px } +/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */ +.tasks-table td:first-child { min-width: 220px; max-width: 340px } +.tasks-table td input[type=number] { width: 92px } +.tasks-table td .field-help { margin-top: 4px } +.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap } /* Headings inside a card separate its parts without a second border. */ .card-body .section-head { margin: 28px 0 12px } .card-body .section-head:first-child { margin-top: 14px } diff --git a/public/assets/task-worker.js b/public/assets/task-worker.js new file mode 100644 index 0000000..980de10 --- /dev/null +++ b/public/assets/task-worker.js @@ -0,0 +1,45 @@ +/* + * Der Takt des geöffneten Backends. + * + * Auf einer Website ohne Besucher gibt es keinen Auslöser für wiederkehrende Aufgaben - außer + * dem Menschen, der gerade in der Verwaltung arbeitet. Dieses Skript klopft in seinem Namen alle + * 60 Sekunden an; der Server entscheidet, ob überhaupt etwas fällig ist. + * + * Drei Eigenschaften, die es unauffällig machen: + * + * - Es fragt nur, während der Reiter sichtbar ist. Ein vergessener Hintergrundreiter soll keine + * Sitzung endlos offen halten und keine Anfragen erzeugen, die niemand sehen will. + * - Es fragt nie zwei Mal gleichzeitig: eine laufende Anfrage blockiert die nächste. + * - Ein Fehlschlag ist kein Ereignis. Kein Hinweis, keine Meldung - beim nächsten Takt wieder. + */ +(function () { + var endpoint = document.querySelector('meta[name="kant-tick"]'); + var token = document.querySelector('meta[name="kant-tick-token"]'); + if (!endpoint || !token || !window.fetch) { + return; + } + var busy = false; + + function tick() { + if (busy || document.visibilityState !== 'visible') { + return; + } + busy = true; + var body = new FormData(); + body.append('_csrf_token', token.content); + fetch(endpoint.content, { method: 'POST', body: body, credentials: 'same-origin' }) + .catch(function () {}) + .then(function () { + busy = false; + }); + } + + // Der erste Takt kommt verzögert: beim Seitenaufbau hat der Browser Wichtigeres zu tun. + window.setTimeout(tick, 5000); + window.setInterval(tick, 60000); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') { + tick(); + } + }); +})(); diff --git a/src/Command/RunTasksCommand.php b/src/Command/RunTasksCommand.php new file mode 100644 index 0000000..83588bc --- /dev/null +++ b/src/Command/RunTasksCommand.php @@ -0,0 +1,98 @@ +addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.') + ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10') + ->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300') + ->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.') + ->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + if (null !== ($name = $input->getOption('task'))) { + $result = $this->scheduler->runNow((string) $name, 'konsole'); + if (null === $result) { + $io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.'); + + return Command::FAILURE; + } + $this->report($io, [$result]); + + return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE; + } + $limit = max(1, (int) $input->getOption('limit')); + $budget = max(1, (int) $input->getOption('budget')); + $loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null; + $deadline = microtime(true) + max(0, (int) $input->getOption('max-time')); + + $failed = false; + while (true) { + $results = $this->scheduler->runDue('konsole', $limit, $budget); + $failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']); + $this->report($io, $results); + if (null === $loop) { + break; + } + // Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung + // über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu. + if (microtime(true) + $loop > $deadline) { + $io->writeln(sprintf('Ende nach %d Sekunden – neu starten lassen.', (int) $input->getOption('max-time'))); + break; + } + sleep($loop); + } + + return $failed ? Command::FAILURE : Command::SUCCESS; + } + + /** @param list $results */ + private function report(SymfonyStyle $io, array $results): void + { + if ([] === $results) { + $io->writeln(sprintf('%s nichts fällig.', gmdate('H:i:s'))); + + return; + } + foreach ($results as $result) { + $io->writeln(sprintf( + '%s %s %s (%d ms): %s', + gmdate('H:i:s'), + 'ok' === $result['status'] ? 'ok ' : 'fehler', + $result['name'], + $result['ms'], + $result['message'], + )); + } + } +} diff --git a/src/Command/TasksCommand.php b/src/Command/TasksCommand.php new file mode 100644 index 0000000..772e721 --- /dev/null +++ b/src/Command/TasksCommand.php @@ -0,0 +1,83 @@ +addOption('log', null, InputOption::VALUE_NONE, 'Zusätzlich die letzten Protokollzeilen.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + // Erst der Abgleich (er legt fehlende Zeilen an und schreibt die Zustandsdatei neu), dann + // die Auskunft daraus - sonst zeigt der erste Aufruf nach einem Update einen Zeitpunkt, + // der eine Sekunde spaeter schon nicht mehr stimmt. + $tasks = $this->scheduler->overview(); + $state = $this->scheduler->fastState(); + $io->writeln(sprintf( + 'Auslöser: Besuch %s, Verwaltung %s. Konsole und URL-Cron laufen immer.', + $state['traffic'] ? 'an' : 'aus', + $state['admin'] ? 'an' : 'aus', + )); + $io->writeln(sprintf( + 'Jetzt ist %s UTC; das nächste Mal steht %s an.', + gmdate('Y-m-d H:i:s'), + null === $state['next'] ? 'unbekannt' : gmdate('Y-m-d H:i:s', $state['next']), + )); + + $rows = []; + foreach ($tasks as $task) { + $rows[] = [ + $task['name'], + $task['enabled'] ? ($task['due'] ? 'fällig' : 'an') : 'aus', + $this->minutes($task['interval']), + $task['next'], + $task['running'] ? 'läuft' : ($task['status'] ?? '–'), + mb_substr($task['message'], 0, 60), + ]; + } + if ([] === $rows) { + $io->warning('Keine Aufgaben. Fehlt die Migration? `php vendor/bin/doctrine-migrations migrate`'); + } else { + $io->table(['Name', 'Zustand', 'Abstand', 'Nächster Lauf (UTC)', 'Zuletzt', 'Meldung'], $rows); + } + + if ($input->getOption('log')) { + $io->section('Protokoll'); + $lines = $this->scheduler->protocol(30); + $io->writeln([] === $lines ? 'Noch nichts protokolliert.' : $lines); + } + + return Command::SUCCESS; + } + + private function minutes(int $seconds): string + { + return $seconds >= 86400 && 0 === $seconds % 86400 + ? sprintf('%d Tag(e)', intdiv($seconds, 86400)) + : sprintf('%d Min.', max(1, intdiv($seconds, 60))); + } +} diff --git a/src/Controller/CronController.php b/src/Controller/CronController.php new file mode 100644 index 0000000..75e7794 --- /dev/null +++ b/src/Controller/CronController.php @@ -0,0 +1,60 @@ + '[a-f0-9]{40}'], methods: ['GET', 'POST'])] + public function run(Request $request, string $token): Response + { + if (!hash_equals(Scheduler::cronToken($this->secret), $token)) { + throw $this->createNotFoundException(); + } + $request->attributes->set(TaskWorkerSubscriber::HANDLED, true); + // Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht. + @ignore_user_abort(true); + @set_time_limit(0); + $began = microtime(true); + $results = $this->scheduler->runDue('cron', 20, 240); + + $lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))]; + foreach ($results as $result) { + $lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']); + } + $lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began); + + return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + 'X-Robots-Tag' => 'noindex, nofollow', + ]); + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index c75a2d9..56538f1 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -222,7 +222,7 @@ final class EditorController extends AbstractController return $this->back('' === trim($request->request->getString('publishAt')) ? 'Die geplante Veröffentlichung wurde entfernt.' - : 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success'); + : 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success'); } #[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])] diff --git a/src/Controller/TaskController.php b/src/Controller/TaskController.php new file mode 100644 index 0000000..d11ed61 --- /dev/null +++ b/src/Controller/TaskController.php @@ -0,0 +1,147 @@ +denyAccessUnlessGranted('ROLE_ADMIN'); + $state = $this->scheduler->fastState(); + + return $this->render('admin/tasks.html.twig', [ + 'tasks' => $this->scheduler->overview(), + 'traffic' => $state['traffic'], + 'admin' => $state['admin'], + 'next' => $state['next'], + 'now' => gmdate('Y-m-d H:i:s'), + 'protocol' => $this->scheduler->protocol(30), + // Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem + // Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert + // sich, wenn das Geheimnis gewechselt wird. + 'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL), + ]); + } + + #[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])] + public function run(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + @set_time_limit(0); + $result = $this->scheduler->runNow($request->request->getString('name')); + if (null === $result) { + return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.'); + } + $this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf( + '%s: %s (%d ms)', + $result['label'], + $result['message'], + $result['ms'], + )); + + return $this->redirectToRoute('admin_tasks'); + } + + /** + * Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken. + * + * Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben + * abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung. + */ + #[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])] + public function configure(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + $this->scheduler->setWorkers( + $request->request->getBoolean('traffic'), + $request->request->getBoolean('admin'), + ); + $enabled = $request->request->all('enabled'); + foreach ($request->request->all('interval') as $name => $minutes) { + $name = (string) $name; + $this->scheduler->setInterval($name, max(1, (int) $minutes) * 60); + $this->scheduler->enable($name, isset($enabled[$name])); + } + $this->addFlash('success', 'Der Plan ist gespeichert.'); + + return $this->redirectToRoute('admin_tasks'); + } + + /** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */ + #[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])] + public function release(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + $this->scheduler->release($request->request->getString('name')); + $this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.'); + + return $this->redirectToRoute('admin_tasks'); + } + + /** + * Der Takt der geöffneten Verwaltung. + * + * Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im + * Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund + * vergessener Reiter soll keine Sitzung endlos offen halten. + * + * Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite. + */ + #[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])] + public function tick(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) { + return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN); + } + $request->attributes->set(TaskWorkerSubscriber::HANDLED, true); + $state = $this->scheduler->fastState(); + if (!$state['admin']) { + return new JsonResponse(['off' => true, 'ran' => []]); + } + @set_time_limit(120); + $ran = $this->scheduler->runDue('verwaltung', 3, 45); + + return new JsonResponse([ + 'ran' => $ran, + 'next' => $this->scheduler->fastState()['next'], + ]); + } + + private function back(string $error): Response + { + $this->addFlash('error', $error); + + return $this->redirectToRoute('admin_tasks'); + } +} diff --git a/src/EventSubscriber/TaskWorkerSubscriber.php b/src/EventSubscriber/TaskWorkerSubscriber.php new file mode 100644 index 0000000..947fafe --- /dev/null +++ b/src/EventSubscriber/TaskWorkerSubscriber.php @@ -0,0 +1,84 @@ + ['onTerminate', -64]]; + } + + public function onTerminate(TerminateEvent $event): void + { + $request = $event->getRequest(); + if ($request->attributes->getBoolean(self::HANDLED)) { + return; + } + $path = $request->getPathInfo(); + if (1 === preg_match(self::IGNORED, $path)) { + return; + } + // Während eines Updates läuft nichts: die Dateien sind vielleicht schon die neuen und + // die Datenbank noch die alte. Und vor der Installation gibt es keine Tabelle. + if (is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE) || !is_file($this->projectDir.'/'.InstallSubscriber::MARKER)) { + return; + } + $admin = str_starts_with($path, '/'.$this->adminPath); + $state = $this->scheduler->fastState(); + if (!($admin ? $state['admin'] : $state['traffic'])) { + return; + } + if (null !== $state['next'] && $state['next'] > time()) { + return; + } + + try { + // Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende + // bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen. + @ignore_user_abort(true); + @set_time_limit($this->budget + 60); + $this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget); + } catch (\Throwable) { + // Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der + // Zeile der Aufgabe und in var/tasks.log - hier ist niemand mehr, dem man es sagen + // könnte: die Antwort ist längst verschickt. + } + } +} diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index faffaf6..484b154 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -36,7 +36,7 @@ final class ArchiveUpdate * ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -, * bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg. */ - private const MARKE = 'var/update/offen.json'; + public const MARKE = 'var/update/offen.json'; /** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */ private const PROTOKOLL = 'var/updates.log'; diff --git a/src/Ops/Scheduler.php b/src/Ops/Scheduler.php new file mode 100644 index 0000000..81565b6 --- /dev/null +++ b/src/Ops/Scheduler.php @@ -0,0 +1,498 @@ +`, wenn der Hoster das anbietet. + * - **Konsole:** `cms:tasks:run`, auch als Dauerlaeufer mit `--loop`. + * + * Was das *nicht* kann, und das gehoert an diese Stelle und nicht ins Kleingedruckte: **ohne + * Anfrage passiert nichts.** Eine Aufgabe fuer 03:00 Uhr laeuft beim ersten Aufruf danach, auf + * einer stillen Website also vielleicht um 07:15 Uhr. Fuer Sitemap, Aufraeumen, Updatepruefung + * und Sicherung ist das brauchbar; fuer etwas, das minutengenau sein muss, ist es das nicht. + * + * Zwei Eigenschaften machen das im Betrieb tragfaehig: + * + * 1. **Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann das + * naechste Mal ueberhaupt etwas ansteht. Solange dieser Zeitpunkt in der Zukunft liegt, ist + * ein Seitenaufruf mit einem `file_get_contents` fertig. + * 2. **Zwei Besucher fuehren eine Aufgabe nicht zweimal aus.** Wer sie ausfuehren darf, wird mit + * einem bedingten UPDATE entschieden - das ist auf MySQL wie auf SQLite atomar. Wer keine + * Zeile veraendert hat, war zu spaet und geht weiter. + */ +final class Scheduler +{ + public const STATE_FILE = 'var/scheduler.json'; + public const LOG_FILE = 'var/tasks.log'; + + /** + * Wie lange eine begonnene Aufgabe als laufend gilt. + * + * Es gibt keinen Weg, das Ende eines Prozesses zu erfahren, den das Hosting mitten im Lauf + * abgeschossen hat. Also verfaellt die Sperre von selbst. Fuenfzehn Minuten sind lang genug + * fuer eine Sicherung und kurz genug, dass eine haengengebliebene Aufgabe denselben Tag + * wieder mitmacht. + */ + public const LOCK_SECONDS = 900; + + /** Obergrenze fuer den wachsenden Abstand nach Fehlschlaegen: sechs Stunden. */ + private const MAX_BACKOFF = 21600; + + /** So viele Zeilen behaelt das Protokoll beim Kuerzen. */ + private const LOG_LINES = 200; + + /** @var array|null */ + private ?array $registry = null; + + /** @param iterable $tasks */ + public function __construct( + private Connection $db, + private iterable $tasks, + private string $projectDir, + ) { + } + + // ---------------------------------------------------------------- Ausfuehren + + /** + * Fuehrt aus, was faellig ist - hoechstens `$limit` Aufgaben und hoechstens `$budget` Sekunden. + * + * Beide Grenzen sind da, weil dieser Aufruf in einer Webanfrage stecken kann. Sie werden + * *zwischen* zwei Aufgaben geprueft, nicht innerhalb einer: eine laufende Sicherung + * abzubrechen waere schlimmer, als sie zu Ende laufen zu lassen. + * + * @return list + */ + public function runDue(string $trigger, int $limit = 3, int $budget = 20, ?\DateTimeImmutable $now = null): array + { + $now ??= self::now(); + $registry = $this->registry(); + $rows = $this->rows(); + // Nach einem Update kennt der Code eine Aufgabe mehr oder eine weniger als die Datenbank. + // Das faellt hier auf, und zwar ohne eine zusaetzliche Abfrage. + if (array_keys($rows) !== array_keys($registry)) { + if (!$this->sync($now)) { + return []; + } + $rows = $this->rows(); + } + $started = microtime(true); + $results = []; + foreach ($this->due($rows, $now) as $row) { + if (count($results) >= $limit || microtime(true) - $started > $budget) { + break; + } + if (!$this->claim((string) $row['name'], $now)) { + // Ein anderer Prozess war schneller. Das ist kein Fehler, das ist der Zweck. + continue; + } + $results[] = $this->execute($registry[(string) $row['name']], $row, $trigger, self::now()); + } + $this->refreshState(); + + return $results; + } + + /** + * Fuehrt eine Aufgabe sofort aus, unabhaengig von ihrem Zeitplan. + * + * Der Knopf "Jetzt ausfuehren" in der Verwaltung. Abgeschaltete Aufgaben laufen so trotzdem - + * wer sie von Hand startet, meint sie auch. Die Sperre gilt aber weiter: zweimal derselbe + * Klick soll nicht zwei Sicherungen anlegen. + * + * Gibt null zurueck, wenn es die Aufgabe nicht gibt oder sie gerade schon laeuft. + * + * @return array{name: string, label: string, status: string, message: string, ms: int}|null + */ + public function runNow(string $name, string $trigger = 'hand'): ?array + { + $now = self::now(); + $registry = $this->registry(); + if (!isset($registry[$name])) { + return null; + } + $this->sync($now); + $rows = $this->rows(); + if (!isset($rows[$name]) || !$this->claim($name, $now, false)) { + return null; + } + $result = $this->execute($registry[$name], $rows[$name], $trigger, self::now()); + $this->refreshState(); + + return $result; + } + + /** + * Sichert sich eine Aufgabe. + * + * Der ganze Schutz gegen doppelte Ausfuehrung steckt in dieser einen Anweisung: die + * Bedingungen stehen im WHERE, nicht in PHP. Zwischen einem `SELECT ... WHERE faellig` und + * einem `UPDATE` liegt ein Moment, in dem ein zweiter Prozess dasselbe liest - hier nicht. + * Wer 1 zurueckbekommt, hat sie; wer 0 zurueckbekommt, war zu spaet. + */ + private function claim(string $name, \DateTimeImmutable $now, bool $onlyWhenDue = true): bool + { + $stamp = $now->format('Y-m-d H:i:s'); + $until = $now->modify('+'.self::LOCK_SECONDS.' seconds')->format('Y-m-d H:i:s'); + $sql = 'UPDATE scheduled_task SET locked_until = ?, last_run_at = ? WHERE name = ? AND (locked_until IS NULL OR locked_until <= ?)'; + $parameters = [$until, $stamp, $name, $stamp]; + if ($onlyWhenDue) { + $sql .= ' AND next_run_at <= ?'; + $parameters[] = $stamp; + } + + try { + return 1 === $this->db->executeStatement($sql, $parameters); + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $row + * + * @return array{name: string, label: string, status: string, message: string, ms: int} + */ + private function execute(Task $task, array $row, string $trigger, \DateTimeImmutable $now): array + { + $begin = microtime(true); + try { + $message = trim($task->run()); + $status = 'ok'; + $failures = 0; + } catch (\Throwable $error) { + // Auch ein Fehler ist ein Ergebnis. Er steht in der Zeile, im Protokoll und auf dem + // Bildschirm "Aufgaben" - und er verlaengert den Abstand bis zum naechsten Versuch, + // damit eine Aufgabe, die nicht laufen *kann*, nicht bei jedem Aufruf neu anfaengt. + $status = 'fehler'; + $message = sprintf('%s (%s:%d)', $error->getMessage(), basename($error->getFile()), $error->getLine()); + $failures = (int) $row['failures'] + 1; + } + $ms = (int) round((microtime(true) - $begin) * 1000); + $interval = max(60, (int) $row['interval_seconds']); + $delay = 'ok' === $status + ? $interval + : (int) min(self::MAX_BACKOFF, $interval * 2 ** min($failures, 6)); + $message = '' === $message ? 'Erledigt.' : mb_substr($message, 0, 480); + + try { + $this->db->executeStatement( + 'UPDATE scheduled_task SET next_run_at = ?, last_status = ?, last_message = ?, last_duration_ms = ?, last_trigger = ?, failures = ?, locked_until = NULL WHERE name = ?', + [ + $now->modify('+'.$delay.' seconds')->format('Y-m-d H:i:s'), + $status, $message, $ms, $trigger, $failures, $task->name(), + ], + ); + } catch (\Throwable) { + // Die Aufgabe ist gelaufen; nur das Nachtragen ging schief. Die Sperre verfaellt von + // selbst, und das Protokoll unten haelt fest, was passiert ist. + } + $this->append(sprintf( + '[%s] %-14s %-7s %6d ms %-11s %s', + $now->format('Y-m-d H:i:s'), + $task->name(), + $status, + $ms, + $trigger, + str_replace(["\r", "\n"], ' ', $message), + )); + + return ['name' => $task->name(), 'label' => $task->label(), 'status' => $status, 'message' => $message, 'ms' => $ms]; + } + + // ---------------------------------------------------------------- Zustand und Verwaltung + + /** + * Der schnelle Blick, ohne Datenbank: darf der Besuchs- beziehungsweise Backend-Ausloeser + * arbeiten, und wann steht ueberhaupt das naechste Mal etwas an? + * + * Fehlt die Datei - frische Installation, geleertes `var/`, kein Schreibrecht -, ist die + * Antwort "ja, und der Zeitpunkt ist unbekannt". Dann fragt der naechste Aufruf die + * Datenbank und schreibt sie neu. Der teure Weg ist der Ausnahmefall, nicht der Normalfall. + * + * @return array{traffic: bool, admin: bool, next: ?int} + */ + public function fastState(): array + { + $raw = @file_get_contents($this->projectDir.'/'.self::STATE_FILE); + $data = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($data)) { + return ['traffic' => true, 'admin' => true, 'next' => null]; + } + + return [ + 'traffic' => (bool) ($data['traffic'] ?? true), + 'admin' => (bool) ($data['admin'] ?? true), + 'next' => isset($data['next']) ? (int) $data['next'] : null, + ]; + } + + /** + * Der Schluessel fuer den URL-Cronjob, abgeleitet aus dem Anwendungsgeheimnis. + * + * Nicht in der Datenbank, weil er dann eine Migration, eine Oberflaeche zum Wechseln und + * einen Weg braeuchte, ihn nach einem Restore wiederzufinden. So ist er auf jeder + * Installation ein anderer, ueberlebt jedes Update und wechselt genau dann, wenn das + * Geheimnis wechselt - also dann, wenn man ihn wechseln will. + */ + public static function cronToken(string $secret): string + { + return substr(hash_hmac('sha256', 'cms-cron', $secret), 0, 40); + } + + /** Schaltet die beiden Ausloeser, die in Anfragen stecken. Konsole und URL-Cron laufen unabhaengig davon weiter. */ + public function setWorkers(bool $traffic, bool $admin): void + { + $this->writeState(['traffic' => $traffic, 'admin' => $admin, 'next' => $this->fastState()['next']]); + } + + /** + * Legt fehlende Zeilen an und entfernt Zeilen ohne Aufgabe im Code. + * + * Neue Aufgaben starten mit einem Abstand von zwei Minuten und nicht sofort: sonst faellt + * direkt nach einem Update alles gleichzeitig an, und zwar im ersten Seitenaufruf danach. + * + * @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und + * zwischen hochgeladenem Code und ausgefuehrter Migration + */ + public function sync(?\DateTimeImmutable $now = null): bool + { + $now ??= self::now(); + $registry = $this->registry(); + + try { + $existing = $this->rows(); + foreach ($registry as $name => $task) { + if (isset($existing[$name])) { + continue; + } + $this->db->insert('scheduled_task', [ + 'name' => $name, + 'interval_seconds' => $task->interval(), + 'enabled' => true, + 'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'), + 'last_message' => '', + 'last_duration_ms' => 0, + 'last_trigger' => '', + 'failures' => 0, + ]); + } + foreach (array_keys($existing) as $name) { + if (!isset($registry[$name])) { + $this->db->executeStatement('DELETE FROM scheduled_task WHERE name = ?', [$name]); + } + } + } catch (TableNotFoundException) { + // Vor der Installation und zwischen hochgeladenem Code und ausgefuehrter Migration. + return false; + } catch (\Throwable) { + return false; + } + $this->refreshState(); + + return true; + } + + /** + * Alles, was der Bildschirm "Aufgaben" zeigt. + * + * @return list + */ + public function overview(?\DateTimeImmutable $now = null): array + { + $now ??= self::now(); + $this->sync($now); + $stamp = $now->format('Y-m-d H:i:s'); + $overview = []; + foreach ($this->rows() as $name => $row) { + $task = $this->registry()[$name] ?? null; + if (null === $task) { + continue; + } + $locked = (string) ($row['locked_until'] ?? ''); + $overview[] = [ + 'name' => $name, + 'label' => $task->label(), + 'explanation' => $task->explanation(), + 'interval' => (int) $row['interval_seconds'], + 'enabled' => (bool) $row['enabled'], + 'next' => (string) $row['next_run_at'], + 'last' => null !== $row['last_run_at'] ? (string) $row['last_run_at'] : null, + 'status' => null !== $row['last_status'] ? (string) $row['last_status'] : null, + 'message' => (string) $row['last_message'], + 'ms' => (int) $row['last_duration_ms'], + 'trigger' => (string) $row['last_trigger'], + 'failures' => (int) $row['failures'], + 'running' => '' !== $locked && $locked > $stamp, + 'due' => (bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp, + ]; + } + + return $overview; + } + + public function enable(string $name, bool $on): void + { + $this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]); + $this->refreshState(); + } + + /** Der Abstand, in Sekunden. Unter einer Minute ergibt er keinen Sinn, ueber einem Monat auch nicht. */ + public function setInterval(string $name, int $seconds): void + { + $this->db->executeStatement( + 'UPDATE scheduled_task SET interval_seconds = ? WHERE name = ?', + [max(60, min(2592000, $seconds)), $name], + ); + $this->refreshState(); + } + + /** Gibt eine haengende Sperre frei - fuer den Fall, dass ein Prozess mitten im Lauf abgebrochen wurde. */ + public function release(string $name): void + { + $this->db->executeStatement('UPDATE scheduled_task SET locked_until = NULL WHERE name = ?', [$name]); + $this->refreshState(); + } + + /** + * Die letzten Zeilen des Protokolls, neueste zuerst. + * + * @return list + */ + public function protocol(int $lines = 40): array + { + $raw = @file_get_contents($this->projectDir.'/'.self::LOG_FILE); + if (!is_string($raw) || '' === trim($raw)) { + return []; + } + + return array_slice(array_reverse(array_values(array_filter(explode("\n", trim($raw))))), 0, $lines); + } + + // ---------------------------------------------------------------- Innenleben + + /** @return array */ + private function registry(): array + { + if (null !== $this->registry) { + return $this->registry; + } + $registry = []; + foreach ($this->tasks as $task) { + $registry[$task->name()] = $task; + } + ksort($registry); + + return $this->registry = $registry; + } + + /** @return array> */ + private function rows(): array + { + try { + $rows = $this->db->fetchAllAssociative('SELECT * FROM scheduled_task'); + } catch (\Throwable) { + return []; + } + $indexed = []; + foreach ($rows as $row) { + $indexed[(string) $row['name']] = $row; + } + ksort($indexed); + + return $indexed; + } + + /** + * @param array> $rows + * + * @return list> + */ + private function due(array $rows, \DateTimeImmutable $now): array + { + $stamp = $now->format('Y-m-d H:i:s'); + $due = []; + foreach ($rows as $row) { + $locked = (string) ($row['locked_until'] ?? ''); + if ((bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp && ('' === $locked || $locked <= $stamp)) { + $due[] = $row; + } + } + // Die am laengsten ueberfaellige zuerst - sonst kommt eine Aufgabe mit kurzem Abstand + // immer wieder dran und eine mit langem nie. + usort($due, static fn (array $a, array $b): int => strcmp((string) $a['next_run_at'], (string) $b['next_run_at'])); + + return $due; + } + + /** + * Schreibt in die Zustandsdatei, wann das naechste Mal etwas ansteht. + * + * Eine laufende Aufgabe zaehlt dabei erst ab dem Ende ihrer Sperre. Ohne das waere waehrend + * einer langen Sicherung jeder Seitenaufruf wieder bei der Datenbank: faellig ist sie ja, + * sie ist nur schon vergeben. Mit dem Ende der Sperre steht in der Datei ein Zeitpunkt in + * der Zukunft, und die uebrigen Anfragen sind nach einem Dateizugriff fertig. + */ + private function refreshState(): void + { + $next = null; + foreach ($this->rows() as $row) { + if (!(bool) $row['enabled']) { + continue; + } + $when = max((string) $row['next_run_at'], (string) ($row['locked_until'] ?? '')); + $stamp = strtotime($when.' UTC'); + if (false !== $stamp && (null === $next || $stamp < $next)) { + $next = $stamp; + } + } + $state = $this->fastState(); + $this->writeState(['traffic' => $state['traffic'], 'admin' => $state['admin'], 'next' => $next]); + } + + /** @param array{traffic: bool, admin: bool, next: ?int} $state */ + private function writeState(array $state): void + { + $file = $this->projectDir.'/'.self::STATE_FILE; + if (!is_dir(dirname($file))) { + @mkdir(dirname($file), 0o775, true); + } + // Schlaegt das fehl - schreibgeschuetztes var/ -, fragt jeder Aufruf wieder die + // Datenbank. Langsamer, aber richtig; ein Fehler ist es nicht. + @file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX); + } + + private function append(string $line): void + { + $file = $this->projectDir.'/'.self::LOG_FILE; + if (!is_dir(dirname($file))) { + @mkdir(dirname($file), 0o775, true); + } + @file_put_contents($file, $line."\n", FILE_APPEND | LOCK_EX); + // Selbst begrenzend, damit das Protokoll auch ohne die Aufraeumaufgabe nie gross wird. + if ((int) @filesize($file) > 100000) { + $lines = array_values(array_filter(explode("\n", (string) @file_get_contents($file)))); + @file_put_contents($file, implode("\n", array_slice($lines, -self::LOG_LINES))."\n", LOCK_EX); + } + } + + private static function now(): \DateTimeImmutable + { + return new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + } +} diff --git a/src/Ops/Tasks/BackupTask.php b/src/Ops/Tasks/BackupTask.php new file mode 100644 index 0000000..4efa00e --- /dev/null +++ b/src/Ops/Tasks/BackupTask.php @@ -0,0 +1,58 @@ +backup->create('automatisch'); + $removed = $this->backup->rotate(); + + return sprintf( + '%s angelegt: %d Seite(n), %d Mediendatei(en).%s', + $result['name'], + $result['manifest']['inhalt']['seiten'], + $result['manifest']['medien']['dateien'], + [] === $removed ? '' : sprintf(' %d alte entfernt.', count($removed)), + ); + } +} diff --git a/src/Ops/Tasks/CleanupTask.php b/src/Ops/Tasks/CleanupTask.php new file mode 100644 index 0000000..da7519d --- /dev/null +++ b/src/Ops/Tasks/CleanupTask.php @@ -0,0 +1,142 @@ +sessions($limit), + $this->updateFolders($limit), + $this->logs(), + ); + } + + /** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */ + private function sessions(int $limit): int + { + $removed = 0; + foreach (glob($this->projectDir.'/var/sessions/*/sess_*') ?: [] as $file) { + if (is_file($file) && (int) @filemtime($file) < $limit && @unlink($file)) { + ++$removed; + } + } + + return $removed; + } + + /** + * Die Arbeitsordner des Archiv-Updates. + * + * Der jüngste ist der Rückweg aus dem letzten Update, und den lässt diese Aufgabe einen + * Monat lang stehen. Wartet ein Update noch auf seinen zweiten Schritt, wird gar nichts + * angefasst: dann ist einer dieser Ordner der Stand, auf den zurückgegangen wird. + */ + private function updateFolders(int $limit): int + { + if (is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) { + return 0; + } + $removed = 0; + foreach (glob($this->projectDir.'/var/update/*', GLOB_ONLYDIR) ?: [] as $folder) { + if ((int) @filemtime($folder) < $limit && $this->removeDirectory($folder)) { + ++$removed; + } + } + + return $removed; + } + + private function logs(): int + { + $shortened = 0; + foreach ([Updater::LOG_FILE, Scheduler::LOG_FILE] as $relative) { + $file = $this->projectDir.'/'.$relative; + $raw = @file_get_contents($file); + if (!is_string($raw)) { + continue; + } + $lines = array_values(array_filter(explode("\n", $raw))); + if (count($lines) <= self::KEEP_LINES) { + continue; + } + if (false !== @file_put_contents($file, implode("\n", array_slice($lines, -self::KEEP_LINES))."\n", LOCK_EX)) { + ++$shortened; + } + } + + return $shortened; + } + + /** Rekursiv löschen, aber nur unterhalb von `var/` – geprüft, nicht angenommen. */ + private function removeDirectory(string $path): bool + { + $real = realpath($path); + $var = realpath($this->projectDir.'/var'); + if (false === $real || false === $var || !str_starts_with($real, $var.DIRECTORY_SEPARATOR)) { + return false; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + return @rmdir($real); + } +} diff --git a/src/Ops/Tasks/PruneStatisticsTask.php b/src/Ops/Tasks/PruneStatisticsTask.php new file mode 100644 index 0000000..2177403 --- /dev/null +++ b/src/Ops/Tasks/PruneStatisticsTask.php @@ -0,0 +1,54 @@ +statistics->prune(); + + return sprintf( + '%d Tag(e) zusammengefasst, %d Zeile(n) jenseits von %d Tagen entfernt.', + $result['compacted'], + $result['removed'], + Statistics::KEEP_DAYS, + ); + } +} diff --git a/src/Ops/Tasks/PublishDueTask.php b/src/Ops/Tasks/PublishDueTask.php new file mode 100644 index 0000000..ee3f35d --- /dev/null +++ b/src/Ops/Tasks/PublishDueTask.php @@ -0,0 +1,48 @@ +pages->publishDue(); + + return [] === $published + ? 'Nichts fällig.' + : sprintf('%d Seite(n) veröffentlicht: /%s', count($published), implode(', /', $published)); + } +} diff --git a/src/Ops/Tasks/Task.php b/src/Ops/Tasks/Task.php new file mode 100644 index 0000000..0adf439 --- /dev/null +++ b/src/Ops/Tasks/Task.php @@ -0,0 +1,44 @@ +archive->latest(); + if (null === $latest) { + throw new \RuntimeException('Die Fassungsliste war nicht erreichbar. Erlaubt dieses Hosting ausgehende Verbindungen?'); + } + $installed = $this->updater->version(); + + return version_compare($latest['version'], $installed, '>') + ? sprintf('Fassung %s ist verfügbar; installiert ist %s.', $latest['version'], $installed) + : sprintf('Aktuell: Fassung %s.', $installed); + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 260f527..6539ee2 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -24,6 +24,9 @@ {% block styles %}{% endblock %} {% block scripts %}{% endblock %} + {# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts` + vollstaendig, und der Takt der Verwaltung darf davon nicht abhaengen. #} + {% block worker %}{% endblock %} diff --git a/templates/admin/icon.html.twig b/templates/admin/icon.html.twig index da257f5..1a72c04 100644 --- a/templates/admin/icon.html.twig +++ b/templates/admin/icon.html.twig @@ -14,6 +14,7 @@ theme: 'M12 3a9 9 0 1 0 0 18Zm0 0a9 9 0 0 1 0 18', settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8-3a8 8 0 0 0-.2-1.7l2-1.5-2-3.5-2.3 1a8 8 0 0 0-3-1.7L14 2h-4l-.5 2.6a8 8 0 0 0-3 1.7l-2.3-1-2 3.5 2 1.5a8 8 0 0 0 0 3.4l-2 1.5 2 3.5 2.3-1a8 8 0 0 0 3 1.7L10 22h4l.5-2.6a8 8 0 0 0 3-1.7l2.3 1 2-3.5-2-1.5c.1-.6.2-1.1.2-1.7Z', system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4', + tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2', users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5', external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5' } %} diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index da19d5b..fbf1df0 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -201,7 +201,7 @@
-

Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald cms:publish-due läuft.

+

Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter Aufgaben.

diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 48ef371..5232f8b 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -2,6 +2,13 @@ click away and the current one is visible. Screens fill in heading, lead, actions and content. #} {% extends 'admin/base.html.twig' %} {% block body_class %}admin-shell{% endblock %} +{# Solange jemand hier arbeitet, laeuft der Aufgabenplan - auch auf einer Website ohne Besucher. + Das Skript fragt im Minutentakt an; was faellig ist, entscheidet der Server. #} +{% block worker %} + + + +{% endblock %} {% block body %}
kantsites @@ -15,7 +22,7 @@ {{ { 'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek', 'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design', - 'settings': 'Einstellungen', 'system': 'System', 'users': 'Benutzer', + 'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer', 'account': 'Mein Konto' }[nav|default('')]|default('Verwaltung') }} @@ -39,6 +46,7 @@
  • {% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design
  • {% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen
  • {% if is_granted('ROLE_ADMIN') %}
  • {% include 'admin/icon.html.twig' with {name: 'system'} only %}System
  • {% endif %} + {% if is_granted('ROLE_ADMIN') %}
  • {% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben
  • {% endif %} {% if is_granted('ROLE_ADMIN') %}
  • {% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer
  • {% endif %}
  • {% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen
  • {% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht
  • diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index 57c9c77..fcc7522 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -186,7 +186,7 @@

    Sicherungen

    {{ backups|length }} vorhanden · var/backups

    Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. Nicht enthalten sind .env, vendor/ und der Quellcode: die kommen aus dem Repository, und .env enthält das Geheimnis dieser Installation.

    -

    Zurückspielen geht bewusst nur auf der Konsole: php bin/console cms:restore <name>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: php bin/console cms:backup.

    +

    Zurückspielen geht bewusst nur auf der Konsole: php bin/console cms:restore <name>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der Aufgabenplan – auch ohne Cronjob.

    diff --git a/templates/admin/tasks.html.twig b/templates/admin/tasks.html.twig new file mode 100644 index 0000000..d853ea9 --- /dev/null +++ b/templates/admin/tasks.html.twig @@ -0,0 +1,131 @@ +{% extends 'admin/shell.html.twig' %} +{% set nav = 'tasks' %} +{% block title %}Aufgaben{% endblock %} +{% block heading %}Wiederkehrende Aufgaben{% endblock %} +{% block lead %}Was regelmäßig laufen soll, wer es anstößt und was zuletzt dabei herauskam. Alle Zeiten in UTC.{% endblock %} +{% block content %} + +{# Der wichtigste Satz zuerst, und zwar als Einschränkung und nicht als Werbung: ohne Anfrage + passiert nichts. Wer das nicht weiß, sucht später den Fehler an der falschen Stelle. #} +
    +
    +

    Wer die Arbeit anstößt

    + {{ next is null ? 'Zeitpunkt noch unbekannt' : 'Nächster Lauf: ' ~ next|date('Y-m-d H:i:s', 'UTC') ~ ' UTC' }} +
    +
    +

    Ein Webhosting ohne Cronjob hat nur eines: Anfragen. Deshalb erledigt eine Anfrage die Arbeit – nach der ausgelieferten Antwort, sodass niemand darauf wartet. Ohne Besuch und ohne geöffnete Verwaltung passiert nichts. Eine Aufgabe für 03:00 Uhr läuft dann beim ersten Aufruf danach, auf einer stillen Website vielleicht um 07:15 Uhr. Wer es genauer braucht, richtet einen der beiden unteren Wege ein.

    + + + + +
    + Auslöser in Anfragen + +

    Jeder Seitenaufruf schaut nach, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei und keine Datenbankabfrage. Ausschalten, wenn ein echter Cronjob die Arbeit übernimmt.

    + +

    Solange dieser Bildschirm offen und sichtbar ist, fragt er jede Minute nach. So läuft der Plan auch auf einer Website, die gerade niemand besucht.

    +
    + +

    Der Plan

    Abstand in Minuten
    + + + + + + {% for task in tasks %} + + + + + + + + + {% else %} + + {% endfor %} + +
    AufgabeAnAbstandNächster LaufZuletzt
    + {{ task.label }} +

    {{ task.explanation }}

    +

    {{ task.name }}

    +
    + + + {% if not task.enabled %}aus + {% elseif task.running %}läuft + {% elseif task.due %}fällig + {% else %}{{ task.next }}{% endif %} + + {% if task.status is null %} + noch nie + {% else %} + {{ task.status }} +

    {{ task.last }} · {{ task.ms }} ms{% if task.trigger %} · durch {{ task.trigger }}{% endif %}

    +

    {{ task.message }}

    + {% if task.failures > 1 %}

    {{ task.failures }} Fehlschläge nacheinander – der Abstand bis zum nächsten Versuch wächst.

    {% endif %} + {% endif %} +
    + + {% if task.running %}{% endif %} +
    Keine Aufgaben. Fehlt die Migration? Der Bildschirm System führt sie aus.
    + + + + {# Die Formulare der Knöpfe stehen außerhalb der Tabelle: ein Formular im Formular gibt es + in HTML nicht, und `form="..."` verbindet Knopf und Formular über die Tabelle hinweg. #} + {% for task in tasks %} + + {% if task.running %} + + {% endif %} + {% endfor %} +
    +
    + +
    +

    Der zuverlässige Weg

    falls dieses Hosting ihn anbietet
    +
    +

    Beide Wege sind unabhängig von den Schaltern oben – sie laufen auch dann, wenn Besuch und Verwaltung ausgeschaltet sind. Wer einen davon einrichtet, sollte den Besuchs-Auslöser abschalten: die Arbeit ist dann schon getan, und die Website antwortet ein wenig schneller.

    +
    +
    URL-Cronjob
    +
    + {{ cronUrl }} +

    Diese Adresse beim Hoster als Cronjob eintragen, zum Beispiel alle fünf Minuten. Der Schlüssel ist aus dem Anwendungsgeheimnis abgeleitet, steht in keiner Datenbank und ändert sich, wenn das Geheimnis gewechselt wird. Er gehört nicht in eine E-Mail und nicht in ein Ticket.

    +
    +
    Konsole
    +
    + php bin/console cms:tasks:run +

    Für einen echten Cronjob, jede Minute. cms:tasks zeigt denselben Plan wie dieser Bildschirm, cms:tasks:run --loop=60 läuft dauerhaft.

    +
    +
    +
    +
    + +
    +

    Protokoll

    die letzten Läufe, neueste zuerst
    +
    + {% if protocol is empty %} +

    Noch nichts gelaufen. Steht in der Spalte „Nächster Lauf“ ein Zeitpunkt, ist alles in Ordnung – der Plan wartet.

    + {% else %} +
    + + + {% for line in protocol %} + + {% endfor %} + +
    {{ line }}
    +
    +

    Aus var/tasks.log. Die Datei kürzt sich selbst; die Aufgabe „Aufräumen“ tut es ebenfalls.

    + {% endif %} +
    +
    + +{% endblock %} diff --git a/templates/install/done.html.twig b/templates/install/done.html.twig index 627ad75..3ef4001 100644 --- a/templates/install/done.html.twig +++ b/templates/install/done.html.twig @@ -13,7 +13,7 @@
    • Impressum und Datenschutzerklärung ausfüllen. Beide Seiten sind angelegt und veröffentlicht; die Pflichtangaben stehen als NOCH AUSZUFÜLLEN darin. Solange sie so dastehen, sind sie eine Baustelle mit Ansage – aber keine Erklärung.
    • Die Datei install.php löschen, falls du über sie installiert hast. Sie löscht sich selbst, aber nachsehen kostet nichts.
    • -
    • Einen täglichen Cronjob einrichten: php bin/console cms:backup für die Sicherung und php bin/console cms:stats:prune für die Statistik. Ohne Sicherung gibt es keinen Rückweg.
    • +
    • Sicherung und Aufräumen laufen bereits. Der Aufgabenplan unter „Aufgaben“ wird von Seitenaufrufen und vom geöffneten Backend angestoßen – ohne Cronjob. Genau, aber nicht minutengenau: ohne Besuch passiert nichts. Wenn dein Hosting einen Cronjob anbietet, trage dort php bin/console cms:tasks:run ein (jede Minute) oder die Adresse des URL-Cronjobs vom Bildschirm „Aufgaben“.
    • HTTPS prüfen. Eine Anmeldung über http:// überträgt das Passwort im Klartext.
    • Inhalt und Gestaltung: Startseite bearbeiten, unter „Design“ Farben und Schriften setzen, unter „Einstellungen“ die Angaben zum Unternehmen ergänzen.
    diff --git a/tests/SchedulerTest.php b/tests/SchedulerTest.php new file mode 100644 index 0000000..0ce6bc0 --- /dev/null +++ b/tests/SchedulerTest.php @@ -0,0 +1,259 @@ +databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-'); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile)); + TestSchema::build($this->db); + $this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6)); + mkdir($this->projectDir.'/var', 0o775, true); + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + @unlink($this->databaseFile); + foreach (glob($this->projectDir.'/var/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($this->projectDir.'/var'); + @rmdir($this->projectDir); + } + + /** @param list $tasks */ + private function scheduler(array $tasks): Scheduler + { + return new Scheduler($this->db, $tasks, $this->projectDir); + } + + private function makeDue(string $name): void + { + $this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]); + } + + public function testTheCodeDecidesWhichTasksExist(): void + { + $this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync(); + self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name')); + + // Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit. + $this->scheduler([new SpyTask('a')])->sync(); + self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task')); + } + + public function testANewTaskDoesNotRunInTheVeryNextRequest(): void + { + // Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach. + $task = new SpyTask('a'); + self::assertSame([], $this->scheduler([$task])->runDue('besuch')); + self::assertSame(0, $task->runs); + } + + public function testWhatIsDueRunsAndIsThenRescheduled(): void + { + $task = new SpyTask('a', 600); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $this->makeDue('a'); + + $results = $scheduler->runDue('besuch'); + self::assertCount(1, $results); + self::assertSame('ok', $results[0]['status']); + self::assertSame('Lauf 1', $results[0]['message']); + self::assertSame(1, $task->runs); + + // Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe. + self::assertSame([], $scheduler->runDue('besuch')); + self::assertSame(1, $task->runs); + $row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']); + self::assertSame('besuch', $row['last_trigger']); + self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']); + self::assertNull($row['locked_until']); + } + + /** + * Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment. + * + * Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet - + * faellig, aber gesperrt. Der zweite muss daran vorbeigehen. + */ + public function testALockedTaskIsLeftAloneByTheNextRequest(): void + { + $task = new SpyTask('a'); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $this->db->executeStatement( + 'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?', + ['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'], + ); + + self::assertSame([], $scheduler->runDue('besuch')); + self::assertSame(0, $task->runs); + } + + /** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */ + public function testAnExpiredLockIsTakenOver(): void + { + $task = new SpyTask('a'); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $this->db->executeStatement( + 'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?', + ['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'], + ); + + self::assertCount(1, $scheduler->runDue('besuch')); + self::assertSame(1, $task->runs); + } + + public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void + { + $task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.')); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $this->makeDue('a'); + + $results = $scheduler->runDue('besuch'); + self::assertSame('fehler', $results[0]['status']); + self::assertStringContainsString('Kein Netz.', $results[0]['message']); + $first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']); + self::assertSame(1, (int) $first['failures']); + // 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der + // gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest. + self::assertNull($first['locked_until']); + self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']); + + $this->makeDue('a'); + $scheduler->runDue('besuch'); + $second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']); + self::assertSame(2, (int) $second['failures']); + self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']); + } + + public function testASwitchedOffTaskNeverRuns(): void + { + $task = new SpyTask('a'); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $scheduler->enable('a', false); + $this->makeDue('a'); + + self::assertSame([], $scheduler->runDue('besuch')); + self::assertSame(0, $task->runs); + + // Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch. + $result = $scheduler->runNow('a'); + self::assertNotNull($result); + self::assertSame(1, $task->runs); + } + + public function testOneRequestDoesNotRunEverythingAtOnce(): void + { + $tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')]; + $scheduler = $this->scheduler($tasks); + $scheduler->sync(); + foreach (['a', 'b', 'c'] as $name) { + $this->makeDue($name); + } + + self::assertCount(2, $scheduler->runDue('besuch', 2)); + self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks))); + } + + /** + * Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt. + * + * Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das, + * was auf geteiltem Hosting am meisten weh tut. + */ + public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void + { + $scheduler = $this->scheduler([new SpyTask('a', 600)]); + $scheduler->sync(); + + $state = $scheduler->fastState(); + self::assertTrue($state['traffic']); + self::assertNotNull($state['next']); + self::assertGreaterThan(time(), $state['next']); + self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE); + + // Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts. + $scheduler->setWorkers(false, true); + $this->makeDue('a'); + $scheduler->runDue('besuch'); + self::assertFalse($scheduler->fastState()['traffic']); + self::assertTrue($scheduler->fastState()['admin']); + } + + /** + * Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg. + * + * Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank: + * faellig ist sie ja, sie ist nur schon vergeben. + */ + public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void + { + $scheduler = $this->scheduler([new SpyTask('a')]); + $scheduler->sync(); + $lockEnds = gmdate('Y-m-d H:i:s', time() + 600); + $this->db->executeStatement( + 'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?', + ['2020-01-01 00:00:00', $lockEnds, 'a'], + ); + + $scheduler->runDue('besuch'); + self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']); + } + + /** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */ + public function testAMissingTableIsNotAnError(): void + { + $this->db->executeStatement('DROP TABLE scheduled_task'); + $task = new SpyTask('a'); + + self::assertSame([], $this->scheduler([$task])->runDue('besuch')); + self::assertSame([], $this->scheduler([$task])->overview()); + self::assertSame(0, $task->runs); + } + + public function testTheProtocolKeepsWhatHappened(): void + { + $scheduler = $this->scheduler([new SpyTask('a')]); + $scheduler->sync(); + $scheduler->runNow('a'); + + $lines = $scheduler->protocol(); + self::assertCount(1, $lines); + self::assertStringContainsString('a', $lines[0]); + self::assertStringContainsString('ok', $lines[0]); + self::assertStringContainsString('hand', $lines[0]); + } + + public function testTheCronKeyDependsOnTheApplicationSecret(): void + { + $one = Scheduler::cronToken('geheimnis-eins'); + $two = Scheduler::cronToken('geheimnis-zwei'); + self::assertSame(40, strlen($one)); + self::assertNotSame($one, $two); + self::assertSame($one, Scheduler::cronToken('geheimnis-eins')); + } +} diff --git a/tests/SpyTask.php b/tests/SpyTask.php new file mode 100644 index 0000000..fbdcd39 --- /dev/null +++ b/tests/SpyTask.php @@ -0,0 +1,53 @@ +name; + } + + public function label(): string + { + return 'Testaufgabe '.$this->name; + } + + public function explanation(): string + { + return 'Nur für Tests.'; + } + + public function interval(): int + { + return $this->interval; + } + + public function run(): string + { + ++$this->runs; + if (null !== $this->error) { + throw $this->error; + } + + return 'Lauf '.$this->runs; + } +} diff --git a/tests/TaskScreenTest.php b/tests/TaskScreenTest.php new file mode 100644 index 0000000..9591afc --- /dev/null +++ b/tests/TaskScreenTest.php @@ -0,0 +1,168 @@ +path = tempnam(sys_get_temp_dir(), 'kant-tasks-web-'); + $url = 'sqlite:///'.str_replace('\\', '/', $this->path); + $this->previous = [$_SERVER, $_ENV]; + foreach (['APP_SECRET' => self::SECRET, 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) { + $_SERVER[$key] = $_ENV[$key] = $value; + } + $this->db = ConnectionFactory::create($url); + TestSchema::build($this->db); + $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']); + foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) { + $this->db->insert('cms_user', [ + 'id' => bin2hex(random_bytes(8)), + 'username' => $username, + 'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]), + 'roles' => json_encode([$role], JSON_THROW_ON_ERROR), + 'created_at' => gmdate('Y-m-d H:i:s'), + 'password_changed_at' => gmdate('Y-m-d H:i:s'), + ]); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->db->close(); + gc_collect_cycles(); + @unlink($this->path); + [$_SERVER, $_ENV] = $this->previous; + } + + public function testTheScreenNamesEveryTaskAndTheWayWithoutVisitors(): void + { + $client = $this->login(); + $client->request('GET', '/admin/aufgaben'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $html = (string) $client->getResponse()->getContent(); + + foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) { + self::assertStringContainsString($task, $html); + } + // Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie + // nicht kennt, sucht den Fehler später an der falschen Stelle. + self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html); + self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html); + // Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr. + self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task')); + } + + public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void + { + self::ensureKernelShutdown(); + $anonymous = self::createClient(); + $anonymous->followRedirects(false); + $anonymous->request('POST', '/admin/aufgaben/ausfuehren'); + self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location')); + + $editor = $this->login('editor'); + $editor->request('GET', '/admin/aufgaben'); + self::assertSame(403, $editor->getResponse()->getStatusCode(), 'Eine Redakteurin verwaltet den Plan nicht.'); + + $admin = $this->login(); + $admin->request('POST', '/admin/aufgaben/ausfuehren', ['_csrf_token' => 'erfunden', 'name' => 'publish-due']); + $admin->followRedirect(); + self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent()); + self::assertNull($this->db->fetchOne('SELECT last_status FROM scheduled_task WHERE name = ?', ['publish-due'])); + } + + public function testAnAdministratorCanRunATaskFromTheScreen(): void + { + $client = $this->login(); + $crawler = $client->request('GET', '/admin/aufgaben'); + $client->submit($crawler->filter('#run-publish-due')->form()); + $client->followRedirect(); + + self::assertStringContainsString('Geplante Veröffentlichungen', (string) $client->getResponse()->getContent()); + $row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['publish-due']); + self::assertSame('ok', $row['last_status']); + self::assertSame('hand', $row['last_trigger']); + self::assertNull($row['locked_until'], 'Nach dem Lauf ist die Sperre weg - sonst blockiert ein Klick die Aufgabe für eine Viertelstunde.'); + } + + /** Der zweite Auslöser: das geöffnete Backend. Er gehört jedem angemeldeten Konto, nicht nur der Verwaltung. */ + public function testTheOpenBackendDrivesThePlan(): void + { + $editor = $this->login('editor'); + $editor->request('GET', '/admin'); + $token = $this->tokenFrom((string) $editor->getResponse()->getContent()); + self::assertNotSame('', $token, 'Jeder Bildschirm der Verwaltung bringt den Takt mit.'); + + $editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => $token]); + self::assertSame(200, $editor->getResponse()->getStatusCode()); + $answer = json_decode((string) $editor->getResponse()->getContent(), true); + self::assertIsArray($answer); + self::assertArrayHasKey('ran', $answer); + + // Ohne gültiges Formular passiert nichts, auch für ein angemeldetes Konto nicht. + $editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => 'erfunden']); + self::assertSame(403, $editor->getResponse()->getStatusCode()); + } + + /** Der dritte Auslöser: ein Aufruf von außen, der nur mit dem richtigen Schlüssel etwas tut. */ + public function testTheUrlCronAnswersOnlyToItsKey(): void + { + self::ensureKernelShutdown(); + $client = self::createClient(); + + $client->request('GET', '/cms-cron/'.str_repeat('a', 40)); + self::assertSame(404, $client->getResponse()->getStatusCode(), 'Ein falscher Schlüssel erfährt nicht einmal, dass es die Stelle gibt.'); + + $client->request('GET', '/cms-cron/'.Scheduler::cronToken(self::SECRET)); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertStringContainsString('text/plain', (string) $client->getResponse()->headers->get('Content-Type')); + self::assertStringContainsString('noindex', (string) $client->getResponse()->headers->get('X-Robots-Tag')); + self::assertStringContainsString('Aufgabe(n)', (string) $client->getResponse()->getContent()); + } + + private function tokenFrom(string $html): string + { + return 1 === preg_match('#followRedirects(false); + $crawler = $client->request('GET', '/admin/login'); + $client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD])); + $client->followRedirect(); + + return $client; + } +} diff --git a/tests/TestSchema.php b/tests/TestSchema.php index 3f0d3b9..3b1d718 100644 --- a/tests/TestSchema.php +++ b/tests/TestSchema.php @@ -18,6 +18,7 @@ use DoctrineMigrations\Version20260912001100; use DoctrineMigrations\Version20260912001200; use DoctrineMigrations\Version20260912001300; use DoctrineMigrations\Version20260913000100; +use DoctrineMigrations\Version20260914000100; use Psr\Log\NullLogger; /** @@ -47,6 +48,7 @@ final class TestSchema Version20260912001200::class, Version20260912001300::class, Version20260913000100::class, + Version20260914000100::class, ] as $migration) { (new $migration($db, new NullLogger()))->up($schema); }