Sicherung und Wiederherstellung - und die Wiederherstellung ist nachgewiesen
Arbeitspaket O. Der Teil, um den es geht, ist nicht das Sichern, sondern das Zurueckspielen: eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen ist, ist eine Vermutung. Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben (VACUUM INTO). Ein copy() waehrend eines Schreibvorgangs ergibt eine Datei, die vorhanden aussieht und sich am Tag der Wiederherstellung nicht oeffnen laesst. Der Medienordner gehoert dazu - ohne die Dateien stellt die Sicherung eine Website voller kaputter Bilder her. .env gehoert nicht dazu: darin steht APP_SECRET, und eine Sicherung wird heruntergeladen und weitergereicht. Was nicht drin ist, steht im Manifest. Jede Sicherung traegt Zeitpunkt, Anlass, Fassung, Migrationsstand, Inhaltszahlen und die SHA-256 der Datenbank. Die Uebersicht zeigt "unversehrt" oder "Pruefsumme falsch" - eine Sicherung, von der man erst beim Zurueckspielen erfaehrt, dass sie kaputt ist, waere schlimmer als keine. Beim Zurueckspielen: neuere Fassung wird abgewiesen (mit --force geht es), zuerst wird der aktuelle Stand gesichert, Wartungszustand an, Medien werden ergaenzt statt ersetzt - eine Datei, die nach der Sicherung hochgeladen wurde, bleibt liegen. Die Massnahme gegen Datenverlust darf nicht selbst welchen verursachen. In der Verwaltung gibt es Liste und Anlegen-Knopf, aber kein Zurueckspielen: ein Knopf, der die ganze Datenbank ersetzt, gehoert nicht neben einen, der eine Seite speichert. Nachgewiesen, zweimal: acht Tests gegen eine wegwerfbare Installation (Sicherung, Seite und Bilddatei loeschen, zurueckspielen, beides wieder da) - und von Hand auf der Entwicklungsinstallation mit einer eigens angelegten Testseite: 5 Seiten, gesichert, geloescht (4), zurueckgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Damit ist der aelteste offene Punkt aus Paket I erledigt. Ein Fall, den erst der Test zeigte: zwei Sicherungen in derselben Sekunde - nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie an. Der Name bekommt jetzt eine Nummer, statt dass die zweite scheitert. Offen: MySQL (mysqldump fehlt) und ein Download ueber den Browser. 344 PHP-Tests / 1397 Assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af3589c95c
commit
8c49f6a67a
12 files changed
+767
-5
No files matched your search
@@ -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 <name>`. 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.
|
||||
@@ -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)%'
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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.*
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Backup;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:backup', description: 'Writes a backup of database and media, and rotates the old ones. Meant for a daily cron job.')]
|
||||
final class BackupCommand extends Command
|
||||
{
|
||||
public function __construct(private Backup $backup)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Restoring is deliberately console only.
|
||||
*
|
||||
* A button that replaces the whole database does not belong next to a button that saves a page.
|
||||
* The administration shows which backups exist and whether they are intact; putting one back is a
|
||||
* decision made with a shell in hand.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:restore', description: 'Puts a backup back in place: checks, safety copy, database, media.')]
|
||||
final class RestoreCommand extends Command
|
||||
{
|
||||
public function __construct(private Backup $backup, private Updater $updater)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Sicherung und Wiederherstellung.
|
||||
*
|
||||
* Three things decide whether this is worth anything:
|
||||
*
|
||||
* 1. **The database copy has to be consistent.** A plain `copy()` of a SQLite file while something
|
||||
* writes produces a file that looks fine and opens with an error later. `VACUUM INTO` asks
|
||||
* SQLite for a complete copy instead.
|
||||
* 2. **The media folder belongs in it.** Documents reference image IDs; without the files the
|
||||
* backup restores a site full of broken pictures.
|
||||
* 3. **`.env` does not.** It holds APP_SECRET, and a backup is something that gets downloaded and
|
||||
* passed around. It is also not needed to restore content.
|
||||
*
|
||||
* Restoring is the part nobody practises, so it is the part with the most checks: a backup from a
|
||||
* newer version is refused, a safety copy of the current state is taken first, and the media are
|
||||
* added rather than replaced - deleting files that were uploaded after the backup would turn a
|
||||
* restore into a second loss.
|
||||
*/
|
||||
final class Backup
|
||||
{
|
||||
public const MANIFEST = 'sicherung.json';
|
||||
/** Keep this many, newest first. Without rotation the disk fills and the next backup fails. */
|
||||
public const KEEP = 10;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private string $databaseUrl,
|
||||
private string $mediaDirectory,
|
||||
private Connection $db,
|
||||
private CommandRunner $runner,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name: string, path: string, manifest: array}
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
public function create(string $reason = 'manuell', \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
|
||||
{
|
||||
// Two backups within the same second must not collide - and the second one must not
|
||||
// fail either: a restore takes a safety copy, and that can land in the same second as the
|
||||
// backup it is about to restore.
|
||||
$name = 'sicherung-'.$now->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<array{name: string, path: string, bytes: int, manifest: ?array, intact: bool}>
|
||||
*/
|
||||
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<array{step: string, ok: bool, detail: string}>, 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);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,38 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
|
||||
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: <code>php bin/console cms:backup</code>.</p>
|
||||
<form action="{{ path('admin_system_backup') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
|
||||
</form>
|
||||
|
||||
{% if backups is not empty %}
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Vorhandene Sicherungen</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Erstellt</th><th scope="col">Fassung</th><th scope="col">Inhalt</th><th scope="col">Größe</th><th scope="col">Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td><code>{{ backup.name }}</code></td>
|
||||
<td>{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
|
||||
<td>{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
|
||||
<td>{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
|
||||
<td>{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
|
||||
<td>{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Verlauf</h2><span class="muted">var/updates.log</span></div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Backups, and above all the half nobody practises: putting one back.
|
||||
*
|
||||
* The test that matters is testARestorePutsDeletedContentBack - everything else is bookkeeping.
|
||||
* A backup function whose restore has never run is a guess.
|
||||
*/
|
||||
final class BackupTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private Connection $db;
|
||||
private Backup $backup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user