diff --git a/README.md b/README.md index d618158..d97cff6 100644 --- a/README.md +++ b/README.md @@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. 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` gehört danach in einen täglichen Cronjob. 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. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts. +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. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts. diff --git a/config/services.yaml b/config/services.yaml index 39b8434..cbb06d0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -32,3 +32,8 @@ services: App\EventSubscriber\MaintenanceSubscriber: arguments: $projectDir: '%kernel.project_dir%' + App\Ops\Backup: + arguments: + $root: '%kernel.project_dir%' + $databaseUrl: '%env(resolve:DATABASE_URL)%' + $mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 28b6e1b..ec0f852 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,6 +440,27 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, **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. +## ADR 038 – Sicherung: die Wiederherstellung ist die Funktion (12.09.2026) + +Umut wollte eine Standard-Sicherungsfunktion. Gebaut ist sie so, dass der Teil im Mittelpunkt steht, den sonst niemand übt. + +**Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben.** Ein `copy()` der Datei während eines Schreibvorgangs ergibt eine Datei, die vorhanden aussieht und sich am Tag der Wiederherstellung nicht öffnen lässt. `VACUUM INTO` liefert einen vollständigen, konsistenten Stand – ohne Wartungszustand, weil SQLite das selbst kann. + +**Der Medienordner gehört dazu, `.env` nicht.** Ohne die Bilddateien stellt die Sicherung eine Website voller kaputter Bilder wieder her; die Dokumente verweisen nur auf IDs. `.env` dagegen enthält `APP_SECRET`, und eine Sicherung ist etwas, das heruntergeladen und weitergereicht wird. Sie wird zum Wiederherstellen von Inhalten auch nicht gebraucht. Im Manifest steht ausdrücklich, was **nicht** drin ist – damit niemand die Sicherung für vollständiger hält, als sie ist. + +**Jede Sicherung trägt ein Manifest** mit Zeitpunkt, Anlass, Fassung, Migrationsstand, Inhaltszahlen und der SHA-256 der Datenbank. Die Übersicht zeigt „unversehrt“ oder „Prüfsumme falsch“, und eine Sicherung mit falscher Prüfsumme wird nicht eingespielt. Eine Sicherung, von der man erst beim Zurückspielen erfährt, dass sie kaputt ist, ist schlimmer als keine. + +**Beim Zurückspielen:** + +- Eine Sicherung aus einer **neueren** Fassung wird abgewiesen – ihr Schema kennt der laufende Code nicht. Mit `--force` geht es trotzdem, weil es Fälle gibt, in denen genau das gewollt ist. +- **Zuerst wird der aktuelle Stand gesichert.** Auch eine Wiederherstellung kann die falsche sein. +- **Medien werden ergänzt, nicht ersetzt.** Eine Datei, die nach der Sicherung hochgeladen wurde, bleibt liegen: die Maßnahme gegen Datenverlust darf nicht selbst welchen verursachen. +- Der Wartungszustand ist an, solange die Datenbank ersetzt wird. + +**Zurückspielen gibt es nur auf der Konsole.** In der Verwaltung steht die Liste mit Zustand und ein Knopf zum Anlegen – ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. + +Ein kleiner Fall, der beim Testen auffiel: zwei Sicherungen in derselben Sekunde. Das ist nicht akademisch – eine Wiederherstellung legt eine Sicherheitskopie an, und die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Der Name bekommt deshalb bei Bedarf eine Nummer, statt dass die zweite Sicherung fehlschlägt. + ## ADR 037 – Seitenbreite, Menüarten und Seiten ohne Kopfbereich (Dokumentschema v16, 12.09.2026) Drei Fragen von Umut, drei unterschiedliche Antworten. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 31aa588..7e5fa16 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -151,7 +151,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt *Braucht: nichts; teils vorziehbar.* -~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben — **als eigenes Arbeitspaket O geführt**, weil Umut daraus eine Funktion haben will und nicht eine Übung. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt). +~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt). Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen. @@ -220,7 +220,7 @@ Danach übernimmt der eigentliche Installer unten: Systemprüfung, Datenbank, Mi Abnahme: **eine einzelne hochgeladene PHP-Datei genügt**, um von einem leeren Webspace zu einer betriebsbereiten Installation zu kommen; eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein manipuliertes Archiv (falsche Prüfsumme) wird abgelehnt, und ein Archiveintrag mit `..` im Pfad landet nirgends. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation. -### O – Sicherung und Wiederherstellung als Funktion (Nutzerauftrag vom 12.09.2026) +### O – Sicherung und Wiederherstellung als Funktion (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 038), außer MySQL und dem Download über den Browser *Braucht: nichts. Für MySQL zusätzlich die Hostingentscheidung aus Arbeitspaket J.* diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 23dfa82..76f9293 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Sicherung und Wiederherstellung, 12.09.2026 + +- **344 PHP-Tests / 1397 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. +- **Der Nachweis, um den es geht:** Sicherung anlegen, eine Seite und eine Bilddatei löschen, zurückspielen – beides ist wieder da. Einmal als Test gegen eine wegwerfbare Installation, einmal von Hand auf der Entwicklungsinstallation mit einer eigens angelegten Testseite (5 Seiten → gesichert → gelöscht, 4 → zurückgespielt, 5 → Testseite entfernt, 4). Damit ist der älteste offene Punkt aus Arbeitspaket I erledigt: „Eine Wiederherstellung ist nachgewiesen.“ +- Geprüft ist außerdem, dass die Kopie der Datenbank eine **benutzbare** Datenbank ist, nicht nur eine Datei, die existiert: der Test öffnet sie und zählt die Seiten darin. +- Eine manipulierte Sicherung wird abgewiesen, bevor irgendetwas angefasst wird – nicht einmal die Sicherheitskopie entsteht. Eine Sicherung aus einer neueren Fassung wird abgewiesen, mit `--force` geht sie durch. Rotation behält die neuesten und meldet, was sie entfernt hat. +- **Ein Fall, den erst der Test zeigte:** zwei Sicherungen in derselben Sekunde. Nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie an – die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Statt die zweite scheitern zu lassen, bekommt der Name eine Nummer. +- **Nicht geprüft:** MySQL (dort fehlt das Sichern über `mysqldump`, der Code sagt es), sehr große Medienordner, und ein Download der Sicherung über den Browser gibt es bewusst noch nicht. + ## Ergänzung: Breite, Menüs, Seiten ohne Kopfbereich, 12.09.2026 - **336 PHP-Tests / 1354 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6634794..be7eeb6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **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: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **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. @@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst. - 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. +- **Sicherung und Wiederherstellung** (ADR 038, Arbeitspaket O): `cms:backup` schreibt Datenbank (über `VACUUM INTO`, nicht als Dateikopie) und Medienordner nach `var/backups/`, mit Manifest samt Prüfsumme, und rotiert die alten. In der Verwaltung gibt es die Übersicht mit Zustand je Sicherung und einen Knopf zum Anlegen. **Zurückspielen nur auf der Konsole** (`cms:restore`): Prüfsumme, Fassungsvergleich, Sicherheitskopie des aktuellen Stands, Wartungszustand, Datenbank ersetzen, Medien **ergänzen** statt ersetzen. Nicht enthalten: `.env`, `vendor/`, Quellcode – steht so im Manifest. - **Seiten ohne Menü, andere Köpfe je Seite** (ADR 037, Dokumentschema v16): jede Seite wählt Kopf- und Fußbereich – Standard, ein anderer Bereich oder **keiner**, für eine Landingpage ohne Navigation. Dafür sind mehrere Kopf- und Fußbereiche erlaubt, jeder mit eigener Adresse; der erste bleibt `default`. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück statt die Navigation stillschweigend zu verlieren. - **Vier Menüplätze statt zwei** (Hauptnavigation, zweite Navigation, Fußzeile, Rechtliches) und **drei Menüarten**: waagerecht, untereinander, aufklappbar als Burger. Der Burger ist ein `details`-Element und öffnet ohne JavaScript. - **Die globale Seitenbreite wirkt jetzt auch nach oben.** `containerWidth` im Designbereich gab es, aber `site.css` deckelte bei 1180 px – schmaler ging, breiter nicht. Dritter Fehler dieser Art an einem Tag: eine Einstellung, die sich speichern ließ und nichts tat. @@ -79,6 +80,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. +- **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. - Vollbild geprüft: vier Abschnittsbreiten werden angenommen und eine erfundene abgewiesen; sieben Höhenangaben, darunter dvh, svh und lvh, und zwei ungültige; ein randloser Abschnitt mit formatfüllendem Bild erzeugt `#id{width:100%}` und `#id img{object-fit:cover;height:100dvh}`. Die Hero-Vorlage ist im JavaScript-Modelltest nachgerechnet, Eigenschaft für Eigenschaft. - Designer geprüft: 16 Datensätze prüfen, dass jede Eigenschaftsform genau die versprochene Deklaration erzeugt – Rahmen je Seite in der richtigen Schreibweise, Ecken, Positionierung, Flex, getrennte Abstände, Innenschatten, Textschatten, Filter in fester Reihenfolge, Mischmodus, und die Bild-Eigenschaften in einer zweiten Regel `#id img{…}`. Jeder davon muss außerdem den Validator überleben, sonst bietet der Editor etwas an, das nicht gespeichert werden kann. Ein Test hält Schema und Editorpanel zusammen: eine Gruppe, die das Panel nicht kennt, würde ihre Eigenschaften in einem unbenannten Aufklappfeld verstecken. diff --git a/src/Command/BackupCommand.php b/src/Command/BackupCommand.php new file mode 100644 index 0000000..99c291d --- /dev/null +++ b/src/Command/BackupCommand.php @@ -0,0 +1,67 @@ +addOption('keep', null, InputOption::VALUE_REQUIRED, 'Wie viele Sicherungen aufbewahrt werden.', (string) Backup::KEEP) + ->addOption('list', null, InputOption::VALUE_NONE, 'Nur auflisten, was vorhanden ist.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + if ($input->getOption('list')) { + $rows = array_map( + static fn (array $backup): array => [ + $backup['name'], + $backup['manifest']['erstellt'] ?? '?', + $backup['manifest']['fassung'] ?? '?', + round($backup['bytes'] / 1048576, 1).' MB', + $backup['intact'] ? 'ok' : 'PRÜFSUMME FALSCH', + ], + $this->backup->all(), + ); + [] === $rows ? $io->writeln('Noch keine Sicherung vorhanden.') : $io->table(['Name', 'Erstellt', 'Fassung', 'Größe', 'Zustand'], $rows); + + return Command::SUCCESS; + } + + try { + $result = $this->backup->create('Konsole'); + } catch (InvalidPage $error) { + $io->error($error->getMessage()); + + return Command::FAILURE; + } + $removed = $this->backup->rotate((int) $input->getOption('keep')); + $io->success(sprintf( + '%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s', + $result['name'], + $result['manifest']['inhalt']['seiten'], + $result['manifest']['inhalt']['fassungen'], + $result['manifest']['medien']['dateien'], + [] === $removed ? '' : ' Entfernt: '.implode(', ', $removed).'.', + )); + $io->writeln('Nicht enthalten: .env, vendor/ und der Quellcode – die kommen aus dem Repository.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/RestoreCommand.php b/src/Command/RestoreCommand.php new file mode 100644 index 0000000..f26c73e --- /dev/null +++ b/src/Command/RestoreCommand.php @@ -0,0 +1,64 @@ +addArgument('name', InputArgument::REQUIRED, 'Name der Sicherung, etwa sicherung-20260912-181159.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Auch eine Sicherung aus einer neueren Fassung einspielen.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = (string) $input->getArgument('name'); + + if ($input->isInteractive() && !$io->confirm(sprintf('Die Datenbank wird durch %s ersetzt. Fortfahren?', $name), false)) { + $io->writeln('Abgebrochen. Es wurde nichts verändert.'); + + return Command::SUCCESS; + } + + // The site says "come back later" while the database is being replaced. + $this->updater->maintenance(true); + $result = $this->backup->restore($name, (bool) $input->getOption('force')); + foreach ($result['steps'] as $step) { + $io->writeln(sprintf('%s %s: %s', $step['ok'] ? ' ok ' : ' FEHLER', $step['step'], $step['detail'])); + } + $this->updater->maintenance(false); + + if (!$result['ok']) { + $io->error('Die Wiederherstellung ist nicht gelaufen. Die Installation ist unverändert.'); + + return Command::FAILURE; + } + $io->success('Wiederhergestellt. Prüfe jetzt die Website und räume danach den Cache: php bin/console cache:clear --env=prod'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 87153e8..6ef64b2 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -2,6 +2,8 @@ namespace App\Controller; +use App\Domain\InvalidPage; +use App\Ops\Backup; use App\Ops\Updater; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route; */ final class SystemController extends AbstractController { - public function __construct(private Updater $updater) + public function __construct(private Updater $updater, private Backup $backup) { } @@ -35,6 +37,7 @@ final class SystemController extends AbstractController 'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null, 'asked' => $request->query->getBoolean('pruefen'), 'steps' => $request->getSession()->remove('update_steps') ?: [], + 'backups' => $this->backup->all(), ]); } @@ -61,6 +64,36 @@ final class SystemController extends AbstractController return $this->redirectToRoute('admin_system'); } + /** + * Creating a backup is safe, so it has a button. Putting one back is not, so it has none - + * `cms:restore` is the way, and the list says which backups exist and whether their checksum + * still matches. + */ + #[Route('/admin/system/sicherung', name: 'admin_system_backup', methods: ['POST'])] + public function backup(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + @set_time_limit(0); + try { + $result = $this->backup->create('Verwaltung'); + } catch (InvalidPage $error) { + return $this->back($error->getMessage()); + } + $removed = $this->backup->rotate(); + + return $this->back(sprintf( + '%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s', + $result['name'], + $result['manifest']['inhalt']['seiten'], + $result['manifest']['inhalt']['fassungen'], + $result['manifest']['medien']['dateien'], + [] === $removed ? '' : ' Ältere entfernt: '.implode(', ', $removed).'.', + ), 'success'); + } + #[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])] public function maintenance(Request $request): Response { diff --git a/src/Ops/Backup.php b/src/Ops/Backup.php new file mode 100644 index 0000000..141910f --- /dev/null +++ b/src/Ops/Backup.php @@ -0,0 +1,314 @@ +format('Ymd-His'); + $directory = $this->directory().'/'.$name; + for ($suffix = 2; is_dir($directory) && $suffix < 100; ++$suffix) { + $name = sprintf('sicherung-%s-%d', $now->format('Ymd-His'), $suffix); + $directory = $this->directory().'/'.$name; + } + if (is_dir($directory)) { + throw new InvalidPage('Es gibt bereits zu viele Sicherungen mit diesem Zeitstempel.'); + } + if (!@mkdir($directory, 0o775, true)) { + throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory)); + } + + $database = $this->copyDatabase($directory); + $media = $this->copyMedia($directory.'/medien'); + $manifest = [ + 'erstellt' => $now->format('c'), + 'anlass' => $reason, + 'fassung' => $this->version(), + // A database that has never been migrated by the tool has no such table; that is not + // a reason to refuse a backup of its content. + 'migration' => $this->migration(), + 'datenbank' => $database, + 'medien' => ['dateien' => $media['count'], 'bytes' => $media['bytes']], + 'inhalt' => [ + 'seiten' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE deleted_at IS NULL'), + 'fassungen' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'), + 'medien' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'), + ], + // Not in here, and it is worth saying so where somebody reads it. + 'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'], + ]; + file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return ['name' => $name, 'path' => $directory, 'manifest' => $manifest]; + } + + /** + * @return list + */ + public function all(): array + { + $backups = []; + foreach (glob($this->directory().'/sicherung-*', GLOB_ONLYDIR) ?: [] as $path) { + $manifest = $this->manifest($path); + $backups[] = [ + 'name' => basename($path), + 'path' => $path, + 'bytes' => $this->size($path), + 'manifest' => $manifest, + // A backup whose database is missing or whose checksum does not match is not a + // backup, and it must say so before somebody relies on it. + 'intact' => null !== $manifest && $this->intact($path, $manifest), + ]; + } + usort($backups, static fn (array $a, array $b): int => strcmp($b['name'], $a['name'])); + + return $backups; + } + + /** Removes the oldest beyond the limit. Returns the names it removed. */ + public function rotate(int $keep = self::KEEP): array + { + $removed = []; + foreach (array_slice($this->all(), max(1, $keep)) as $backup) { + $this->remove($backup['path']); + $removed[] = $backup['name']; + } + + return $removed; + } + + /** + * Puts a backup back in place. + * + * @return array{steps: list, ok: bool} + */ + public function restore(string $name, bool $force = false): array + { + $steps = []; + $step = static function (array &$steps, string $name, bool $ok, string $detail): bool { + $steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail]; + + return $ok; + }; + $path = $this->directory().'/'.basename($name); + $manifest = $this->manifest($path); + if (!$step($steps, 'Sicherung finden', null !== $manifest, null === $manifest + ? sprintf('%s gibt es nicht oder es fehlt das Manifest.', basename($name)) + : sprintf('%s vom %s, Fassung %s.', basename($name), $manifest['erstellt'], $manifest['fassung']))) { + return ['steps' => $steps, 'ok' => false]; + } + if (!$step($steps, 'Unversehrt', $this->intact($path, $manifest), $this->intact($path, $manifest) + ? 'Die Prüfsumme der Datenbank stimmt.' + : 'Die Prüfsumme der Datenbank stimmt nicht. Diese Sicherung wird nicht eingespielt.')) { + return ['steps' => $steps, 'ok' => false]; + } + // A backup from a newer version carries a schema this code does not know. + $newer = version_compare((string) $manifest['fassung'], $this->version(), '>'); + if (!$step($steps, 'Fassung passt', !$newer || $force, $newer + ? sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s. Erst aktualisieren.', $manifest['fassung'], $this->version()) + : sprintf('Sicherung %s, installiert %s.', $manifest['fassung'], $this->version()))) { + return ['steps' => $steps, 'ok' => false]; + } + + try { + $safety = $this->create('vor Wiederherstellung'); + $step($steps, 'Aktuellen Stand sichern', true, $safety['name'].' – auch eine Wiederherstellung kann die falsche sein.'); + } catch (InvalidPage $error) { + $step($steps, 'Aktuellen Stand sichern', false, $error->getMessage()); + + return ['steps' => $steps, 'ok' => false]; + } + + $file = $this->databaseFile(); + if (null === $file) { + $step($steps, 'Datenbank zurückschreiben', false, 'Diese Installation nutzt keine Dateidatenbank. Für MySQL fehlt das Einspielen noch (siehe docs/UPDATE.md).'); + + return ['steps' => $steps, 'ok' => false]; + } + $this->db->close(); + $copied = @copy($path.'/'.basename($file), $file); + if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) { + return ['steps' => $steps, 'ok' => false]; + } + + // Media are added, never deleted: files uploaded after the backup would otherwise be lost + // by the very operation that is meant to prevent loss. + $restored = $this->restoreMedia($path.'/medien'); + $step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored)); + + return ['steps' => $steps, 'ok' => true]; + } + + private function migration(): string + { + try { + return (string) $this->db->fetchOne('SELECT MAX(version) FROM migration_versions'); + } catch (\Throwable) { + return 'unbekannt'; + } + } + + public function directory(): string + { + return $this->root.'/var/backups'; + } + + public function version(): string + { + $file = $this->root.'/VERSION'; + + return is_readable($file) ? trim((string) file_get_contents($file)) : '0.0.0'; + } + + /** @return array{datei: string, bytes: int, sha256: string} */ + private function copyDatabase(string $directory): array + { + $file = $this->databaseFile(); + if (null === $file) { + if (!$this->runner->has('mysqldump', $this->root)) { + throw new InvalidPage('Diese Installation nutzt keine Dateidatenbank, und mysqldump ist nicht aufrufbar. Ohne eines von beiden gibt es keine Sicherung.'); + } + throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).'); + } + $target = $directory.'/'.basename($file); + // VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and + // it would not say so until the day it is needed. The path is ours, not user input. + $this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target)))); + if (!is_readable($target)) { + throw new InvalidPage('Die Datenbank ließ sich nicht sichern.'); + } + + return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)]; + } + + /** @return array{count: int, bytes: int} */ + private function copyMedia(string $target): array + { + if (!is_dir($this->mediaDirectory)) { + return ['count' => 0, 'bytes' => 0]; + } + @mkdir($target, 0o775, true); + $count = 0; + $bytes = 0; + foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) { + if (is_file($file) && @copy($file, $target.'/'.basename($file))) { + ++$count; + $bytes += (int) filesize($file); + } + } + + return ['count' => $count, 'bytes' => $bytes]; + } + + private function restoreMedia(string $source): int + { + if (!is_dir($source)) { + return 0; + } + @mkdir($this->mediaDirectory, 0o775, true); + $count = 0; + foreach (glob($source.'/*') ?: [] as $file) { + if (is_file($file) && @copy($file, $this->mediaDirectory.'/'.basename($file))) { + ++$count; + } + } + + return $count; + } + + private function manifest(string $path): ?array + { + $file = $path.'/'.self::MANIFEST; + if (!is_readable($file)) { + return null; + } + try { + $manifest = json_decode((string) file_get_contents($file), true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + return is_array($manifest) ? $manifest : null; + } + + private function intact(string $path, array $manifest): bool + { + $file = $path.'/'.($manifest['datenbank']['datei'] ?? ''); + if (!is_readable($file)) { + return false; + } + + return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file)); + } + + private function size(string $path): int + { + $bytes = 0; + foreach (glob($path.'/*') ?: [] as $entry) { + $bytes += is_dir($entry) ? $this->size($entry) : (int) filesize($entry); + } + + return $bytes; + } + + private function remove(string $path): void + { + foreach (glob($path.'/*') ?: [] as $entry) { + is_dir($entry) ? $this->remove($entry) : @unlink($entry); + } + @rmdir($path); + } + + private function databaseFile(): ?string + { + if (!str_starts_with($this->databaseUrl, 'sqlite:')) { + return null; + } + $path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl); + + return '' === $path ? null : str_replace('\\', '/', (string) $path); + } +} diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index 739e149..ddfacbb 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -95,6 +95,38 @@ {% endif %} +
+

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.

+
+ + +
+ + {% if backups is not empty %} + + + + + {% for backup in backups %} + + + + + + + + + {% endfor %} + +
Vorhandene Sicherungen
NameErstelltFassungInhaltGrößeZustand
{{ backup.name }}{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}
{{ backup.manifest.anlass }}{% endif %}
{{ backup.manifest ? backup.manifest.fassung : '—' }}{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB{% if backup.intact %}unversehrt{% else %}Prüfsumme falsch{% endif %}
+

„Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.

+ {% endif %} +
+
+

Verlauf

var/updates.log
diff --git a/tests/BackupTest.php b/tests/BackupTest.php new file mode 100644 index 0000000..b11518f --- /dev/null +++ b/tests/BackupTest.php @@ -0,0 +1,215 @@ +root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-backup-'.bin2hex(random_bytes(6)); + mkdir($this->root.'/var/media', 0o775, true); + file_put_contents($this->root.'/VERSION', "1.1.0\n"); + // Not part of a backup, and the test says so below. + file_put_contents($this->root.'/.env', "APP_SECRET=geheim\n"); + + $url = 'sqlite:///'.$this->root.'/var/cms.db'; + $this->db = ConnectionFactory::create($url); + TestSchema::build($this->db); + $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']); + foreach (['p1' => 'home', 'p2' => 'leistungen'] as $id => $slug) { + $this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); + } + file_put_contents($this->root.'/var/media/bild-480.webp', 'erstes bild'); + + $this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner()); + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + $this->remove($this->root); + } + + public function testABackupHoldsTheDatabaseTheMediaAndAManifestButNoSecrets(): void + { + $result = $this->backup->create('Test'); + $path = $result['path']; + + self::assertFileExists($path.'/cms.db'); + self::assertFileExists($path.'/medien/bild-480.webp'); + self::assertFileExists($path.'/'.Backup::MANIFEST); + // The secret stays out: a backup gets downloaded and passed around. + self::assertFileDoesNotExist($path.'/.env'); + self::assertFileDoesNotExist($path.'/.env.local'); + + $manifest = $result['manifest']; + self::assertSame('1.1.0', $manifest['fassung']); + self::assertSame(2, $manifest['inhalt']['seiten']); + self::assertSame(1, $manifest['medien']['dateien']); + self::assertSame(hash_file('sha256', $path.'/cms.db'), $manifest['datenbank']['sha256']); + self::assertContains('.env', $manifest['enthaelt_nicht']); + + // The copy is a usable database, not a file that merely exists. + $copy = ConnectionFactory::create('sqlite:///'.$path.'/cms.db'); + self::assertSame(2, (int) $copy->fetchOne('SELECT COUNT(*) FROM page')); + $copy->close(); + + $listed = $this->backup->all(); + self::assertCount(1, $listed); + self::assertTrue($listed[0]['intact']); + self::assertGreaterThan(0, $listed[0]['bytes']); + } + + public function testARestorePutsDeletedContentBack(): void + { + $backup = $this->backup->create('vor dem Unfall'); + + // The accident: a page and an image are gone. + $this->db->executeStatement('DELETE FROM page WHERE id = ?', ['p2']); + unlink($this->root.'/var/media/bild-480.webp'); + self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); + + $result = $this->backup->restore($backup['name']); + self::assertTrue($result['ok'], $this->explain($result)); + + // A fresh connection, because the file underneath was replaced. + $after = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db'); + self::assertSame(2, (int) $after->fetchOne('SELECT COUNT(*) FROM page')); + self::assertSame('leistungen', $after->fetchOne('SELECT path FROM page WHERE id = ?', ['p2'])); + $after->close(); + self::assertFileExists($this->root.'/var/media/bild-480.webp'); + + // A safety copy of the state before the restore was taken first. + self::assertContains('Aktuellen Stand sichern', array_column($result['steps'], 'step')); + self::assertCount(2, $this->backup->all(), 'The safety copy is a backup of its own.'); + } + + public function testARestoreKeepsFilesThatWereAddedAfterTheBackup(): void + { + $backup = $this->backup->create('Test'); + // Uploaded after the backup: a restore must not delete it, or the operation meant to + // prevent loss would cause one. + file_put_contents($this->root.'/var/media/spaeter-480.webp', 'neueres bild'); + + self::assertTrue($this->backup->restore($backup['name'])['ok']); + self::assertFileExists($this->root.'/var/media/spaeter-480.webp'); + self::assertFileExists($this->root.'/var/media/bild-480.webp'); + } + + public function testATamperedBackupIsNotRestored(): void + { + $backup = $this->backup->create('Test'); + file_put_contents($backup['path'].'/cms.db', 'kaputt'); + + self::assertFalse($this->backup->all()[0]['intact']); + $result = $this->backup->restore($backup['name']); + self::assertFalse($result['ok']); + self::assertSame('Unversehrt', $this->firstFailed($result)['step']); + // Nothing was touched - not even a safety copy, because it never got that far. + self::assertCount(1, $this->backup->all()); + self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); + } + + public function testABackupFromANewerVersionIsRefusedUnlessForced(): void + { + $backup = $this->backup->create('Test'); + // The installation goes back to an older version, as after a rollback. + file_put_contents($this->root.'/VERSION', "1.0.0\n"); + + $refused = $this->backup->restore($backup['name']); + self::assertFalse($refused['ok']); + self::assertSame('Fassung passt', $this->firstFailed($refused)['step']); + self::assertStringContainsString('Erst aktualisieren', $this->firstFailed($refused)['detail']); + + // With force it runs, because sometimes that is exactly what is wanted. + self::assertTrue($this->backup->restore($backup['name'], true)['ok']); + } + + public function testAMissingBackupIsSaidSoRatherThanGuessed(): void + { + $result = $this->backup->restore('sicherung-gibt-es-nicht'); + self::assertFalse($result['ok']); + self::assertSame('Sicherung finden', $this->firstFailed($result)['step']); + } + + public function testRotationKeepsTheNewestAndRemovesTheRest(): void + { + $names = []; + for ($index = 0; $index < 4; ++$index) { + $names[] = $this->backup->create('Test', new \DateTimeImmutable('2026-09-1'.$index.' 10:00:00', new \DateTimeZone('UTC')))['name']; + } + self::assertCount(4, $this->backup->all()); + + $removed = $this->backup->rotate(2); + $left = array_column($this->backup->all(), 'name'); + self::assertCount(2, $left); + // The newest two survive; rotation without a limit would let the disk fill up and the + // next backup fail exactly when it is needed. + self::assertSame([$names[3], $names[2]], $left); + self::assertSame([$names[1], $names[0]], $removed); + } + + public function testTwoBackupsInTheSameSecondGetTwoDirectories(): void + { + // This is not academic: a restore takes a safety copy, and that lands in the same second + // as the backup it is about to restore. + $moment = new \DateTimeImmutable('2026-09-12 10:00:00', new \DateTimeZone('UTC')); + $first = $this->backup->create('Test', $moment); + $second = $this->backup->create('Test', $moment); + + self::assertNotSame($first['name'], $second['name']); + self::assertSame('sicherung-20260912-100000', $first['name']); + self::assertSame('sicherung-20260912-100000-2', $second['name']); + self::assertCount(2, $this->backup->all()); + } + + private function firstFailed(array $result): array + { + foreach ($result['steps'] as $step) { + if (!$step['ok']) { + return $step; + } + } + self::fail('Kein gescheiterter Schritt: '.$this->explain($result)); + } + + private function explain(array $result): string + { + return implode("\n", array_map( + static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']), + $result['steps'], + )); + } + + private function remove(string $path): void + { + if (!is_dir($path)) { + @unlink($path); + + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ('.' !== $entry && '..' !== $entry) { + $this->remove($path.'/'.$entry); + } + } + @rmdir($path); + } +}