Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d476177a | ||
|
|
ea3dd4ddf2 |
No files matched your search
@@ -88,6 +88,8 @@ npm.cmd run audit
|
|||||||
|
|
||||||
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
||||||
|
|
||||||
|
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
|
||||||
|
|
||||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||||
|
|
||||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ services:
|
|||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
$adminPath: '%kant.admin_path%'
|
$adminPath: '%kant.admin_path%'
|
||||||
$environment: '%kernel.environment%'
|
$environment: '%kernel.environment%'
|
||||||
|
App\Ops\ArchiveUpdate:
|
||||||
|
arguments:
|
||||||
|
$root: '%kernel.project_dir%'
|
||||||
App\Ops\Updater:
|
App\Ops\Updater:
|
||||||
arguments:
|
arguments:
|
||||||
$root: '%kernel.project_dir%'
|
$root: '%kernel.project_dir%'
|
||||||
|
|||||||
@@ -440,6 +440,52 @@ 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.
|
**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 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
|
||||||
|
|
||||||
|
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: **Leistung 96 (Desktop) und 98 (Mobil)**, alles andere 100. Der Grund stand im Bericht: `LCP request discovery` – das größte Element über der Falz ist dort kein `<img>`, sondern der **Hintergrund des ersten Abschnitts**.
|
||||||
|
|
||||||
|
Das ist ein struktureller Unterschied, kein Zufall: Ein `<img>` steht im HTML, der Browser sieht es beim ersten Durchlesen und beginnt zu laden. Ein Hintergrundbild steht im Stylesheet – der Browser erfährt erst von ihm, wenn er das CSS geholt und geparst hat. Die Regel aus ADR 039 („das erste Bild einer Seite lädt sofort") griff genau deshalb nicht: Sie sucht nach einem Bildbaustein, und es gab keinen.
|
||||||
|
|
||||||
|
**Drei Vorabverweise mit Medienabfragen, nicht ein `imagesrcset`.** Das war die einzige Entscheidung, die wirklich eine war. Ein `srcset` wählt nach Breite, die Regeln des Compilers wählen nach Gerät – bei einem 1200 px breiten Fenster nähme das eine die 1600er Datei und das andere die 1920er, und der Browser lüde **zwei** Bilder statt einem. Dann kostet das Vorabholen mehr, als es bringt – genau der Fehler, der in diesem Projekt schon einmal fünf Punkte gekostet hat. Die drei Verweise tragen deshalb dieselben Grenzen wie der Compiler (`StyleCompiler::PRELOAD_DEVICES`), und die Variantenwahl ist eine einzige öffentliche Methode, die beide Seiten benutzen. Der Test vergleicht nicht nur, dass Verweise da sind, sondern dass **jeder Verweis dieselbe Datei meint wie die Regel**.
|
||||||
|
|
||||||
|
**Gemessen danach: Desktop 97, Mobil 99**, LCP 1,3 s → 1,1 s. Kein Sprung auf 100 – was bleibt, ist das blockierende Stylesheet (300 ms FCP), und das ist zum großen Teil der Entwicklungsserver ohne Kompression, siehe `docs/QUALITY.md`.
|
||||||
|
|
||||||
|
**Und ein Nebenfund, der mehr brachte als der Hauptpunkt.** Der Kopf fragte die Mediathek nach demselben Bild, das der Compiler ohnehin schon dreimal gesucht hatte – je Gerät eine Regel, je Regel eine Abfrage. `MediaLibrary::find()` merkt sich jetzt, wonach es in dieser Anfrage schon gefragt wurde: **Startseite 19 → 15 Abfragen**, und zwar einschließlich der Abfrage, die der Vorabverweis neu gebracht hat. Der Merkzettel lebt nur so lange wie die Anfrage, also kann er nicht veralten; beim Hochladen und Ändern wird er trotzdem geleert, weil derselbe Aufruf danach noch liest.
|
||||||
|
|
||||||
|
## ADR 068 – Update aus dem Releasearchiv, in zwei Schritten (13.09.2026)
|
||||||
|
|
||||||
|
Umut: „Dann Updater für Hoster optimieren?!" Der Updater aus ADR 035 setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über das Releasearchiv installiert, und die Verwaltung sagte danach „Dieses Verzeichnis ist keine Git-Arbeitskopie". Also kein Update, außer von Hand über FTP. Das ist genau der Fall, für den dieses CMS gedacht ist.
|
||||||
|
|
||||||
|
`ArchiveUpdate` geht denselben Weg wie die Erstinstallation: Archiv laden, Prüfsumme vergleichen, entpacken, Dateien ersetzen, migrieren. Drei Unterschiede trennen ein Update von einer Installation, und jeder davon ist eine Entscheidung:
|
||||||
|
|
||||||
|
**Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben stehen. Deshalb wird `public/` als einziges Verzeichnis **Datei für Datei zusammengeführt** statt ersetzt: dort liegen die Dateien der Anwendung und die des Betreibers nebeneinander. Ein `rename()` des ganzen Ordners wäre eine Zeile weniger Code und hätte die Schriften gelöscht.
|
||||||
|
|
||||||
|
**Der alte Stand wird beiseitegelegt, nicht gelöscht.** Er liegt unter `var/update/<Zeitstempel>/alt/`, bis jemand ihn entfernt. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er schon bewegt hat, und nimmt den Wartungszustand wieder heraus.
|
||||||
|
|
||||||
|
**Es sind zwei Knöpfe, und das ist kein Umweg.** Der erste ersetzt die Dateien, der zweite führt die Migrationen aus. Dazwischen liegt eine neue Anfrage – denn ein PHP-Prozess, der sich selbst ausgetauscht hat, arbeitet mit den Klassen weiter, die er beim Start geladen hat. Migrationen im selben Durchgang liefen mit dem Code der alten Fassung. Der Wartungszustand bleibt zwischen beiden Schritten an, und die Oberfläche sagt, dass er es tut.
|
||||||
|
|
||||||
|
**Geprüft wird das ohne Netz.** Das Herunterladen ist in `Download` gewandert – eine Klasse, die genau eine Sache tut und die HTTPS-Pflicht an einer einzigen Stelle durchsetzt. Der Test stellt eine Releaseseite in den Speicher und lässt den Rest echt laufen: echte Zip-Archive, echte Prüfsummen, echte Umbenennungen. Die Frage, um die es dabei geht, ist nicht „läuft der Download", sondern: Steht die hochgeladene Schrift danach noch da? Ein Test, der wirklich `git.kant-it.de` abfragt, wäre rot, wenn der Server umzieht, und grün, wenn im Archiv Unsinn steht.
|
||||||
|
|
||||||
|
Dazu kommt ein Test, der die Ersetzungsliste des Updaters gegen die Mitnahmeliste von `scripts/build-release.php` hält. Denselben Fehler gab es schon einmal andersherum: `migrations.php` fehlte im Archiv, und es fiel erst auf Umuts Hosting auf.
|
||||||
|
|
||||||
|
## ADR 067 – Die Verwaltung auf dem Telefon, und eigene Symbole (13.09.2026)
|
||||||
|
|
||||||
|
Umut: „Bitte Backend mobiloptimieren, das Menü ist schlecht." Und kurz darauf: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem Smartphone kommen sonst Emojis."
|
||||||
|
|
||||||
|
Zwei Beobachtungen, eine Ursache: Die Verwaltung war für einen breiten Bildschirm gebaut. Zwölf Navigationspunkte nebeneinander ergeben auf 390 px eine Zeile, die seitlich hinausläuft.
|
||||||
|
|
||||||
|
**Das Menü ist jetzt ein `<details>`**, das ab 760 px Breite aufgeklappt und darunter zugeklappt startet; die Zusammenfassung nennt den Bereich, in dem man gerade steht. Kein Skript, kein Zustand, der verloren gehen kann – und mit Tastatur bedienbar, weil der Browser das mitbringt. Die Ziele sind 44 px hoch, was keine Gestaltungsfrage ist, sondern die Größe einer Fingerkuppe.
|
||||||
|
|
||||||
|
**Die Symbole waren Unicode-Zeichen** (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie aus wie Symbole; ein Telefon ersetzt sie durch **Emoji** – buntes Zahnrad, bunter Briefumschlag, jedes in einem anderen Stil, und nichts davon in der Schrift der Oberfläche. Jetzt sind es zwölf eigene SVG-Pfade in einer Vorlage: eine Linienstärke, `currentColor`, kein zusätzlicher Request, keine Symbolschrift. Ein Test sucht in der Navigationsvorlage nach Zeichen aus den Emoji-Bereichen und hält jeden verwendeten Namen gegen die Sammlung – ein fehlender Name würde sonst still das Ersatzsymbol zeichnen, und zwei Einträge sähen gleich aus. Genau das war der Fall: „Formulare" und „Benutzer" trugen denselben Pfad.
|
||||||
|
|
||||||
|
## ADR 066 – Ein Farbfeld, das beides kann (13.09.2026)
|
||||||
|
|
||||||
|
Umut: „Wieso kein Colorpicker?" Berechtigte Frage. Die Farbfelder im Designbereich waren Textfelder – wer Grün wollte, musste `#2e7d5b` wissen.
|
||||||
|
|
||||||
|
Der Grund dafür war echt: Ein `<input type="color">` kann **nur** eine Farbe. Die Gestaltung dieses CMS kennt aber auch `@akzent` – einen Verweis auf die Palette, der sich ändert, wenn die Palette sich ändert. Ein Farbwähler hätte daraus beim ersten Klick einen festen Wert gemacht und die Verbindung stillschweigend gekappt.
|
||||||
|
|
||||||
|
**Das Textfeld bleibt deshalb maßgeblich**, und daneben stehen ein Farbfeld und eine Auswahl der Palettenfarben. Wer im Farbfeld zieht, schreibt einen Hexwert in das Textfeld; wer `@akzent` tippt, behält den Verweis, und das Farbfeld zeigt daneben nur an, wie diese Farbe gerade aussieht. Aufgebaut wird das von einem kleinen Skript über das vorhandene Feld: Ohne JavaScript bleibt das Textfeld übrig und die Seite vollständig bedienbar.
|
||||||
|
|
||||||
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
|
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
|
||||||
|
|
||||||
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
|
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media"
|
|||||||
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
|
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
|
||||||
Datenschutzgerüst gleich mit an.
|
Datenschutzgerüst gleich mit an.
|
||||||
|
|
||||||
|
**Die Verwaltung muss nicht `/admin` heißen.** `ADMIN_PATH` in `.env` setzt die Adresse; `php
|
||||||
|
bin/console cms:admin:path <neu>` prüft, ändert und sagt, welcher Zwischenspeicher danach zu leeren
|
||||||
|
ist. Bei einer Kundenwebsite lohnt das: `/admin` ist die erste Adresse, die ein Scanner probiert.
|
||||||
|
|
||||||
Zum Ansehen ein eigener Server auf einem eigenen Port:
|
Zum Ansehen ein eigener Server auf einem eigenen Port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -96,6 +100,10 @@ Dokument nichts einfügen.
|
|||||||
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
|
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
|
||||||
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
|
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
|
||||||
|
|
||||||
|
Jedes Farbfeld hat einen Farbwähler und die Palettenfarben zur Auswahl. **Das Textfeld daneben
|
||||||
|
bleibt maßgeblich**: wer dort `@accent` stehen lässt, behält den Verweis auf die Palette; wer im
|
||||||
|
Farbwähler zieht, schreibt einen festen Wert hinein und kappt ihn.
|
||||||
|
|
||||||
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
|
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
|
||||||
hier schon zweimal die Ursache eines „geht nicht".
|
hier schon zweimal die Ursache eines „geht nicht".
|
||||||
|
|
||||||
@@ -115,6 +123,9 @@ falsch – nicht „nur die Vorschau".
|
|||||||
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
|
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
|
||||||
960er Datei.
|
960er Datei.
|
||||||
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
|
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
|
||||||
|
- „Beim Klick" entscheidet je Bild, ob nichts passiert, ob es sich vergrößert oder ob es zu einer
|
||||||
|
Adresse führt; eine Galerie hat dafür einen Schalter. Zugrunde liegt ein gewöhnlicher Verweis
|
||||||
|
auf die große Fassung – ohne JavaScript öffnet ein Klick sie, nichts ist kaputt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -184,6 +195,22 @@ Adressen prüfen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11. Updates dieser Installation
|
||||||
|
|
||||||
|
Zwei Wege, und welcher gilt, entscheidet das Hosting:
|
||||||
|
|
||||||
|
- **Mit Git** (eigener Server, Shell-Zugriff): Bildschirm „System" oder `php bin/console cms:update`.
|
||||||
|
Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg.
|
||||||
|
- **Ohne Git** (geteiltes Hosting, über `install.php` installiert): im selben Bildschirm die Karte
|
||||||
|
„Update aus dem Releasearchiv" – zwei Knöpfe, der zweite führt die Migrationen aus. `var/`, `.env`,
|
||||||
|
hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt.
|
||||||
|
|
||||||
|
Beides steht ausführlich in `docs/UPDATE.md`. Wichtig für den, der eine Website baut: **nichts von
|
||||||
|
dem, was diese Website ausmacht, liegt im Code**, also überlebt es beide Wege. Wer dafür doch eine
|
||||||
|
Twig-Datei angefasst hat, verliert die Änderung beim ersten Update – siehe Abschnitt 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Was in diesem Projekt immer wieder schiefging
|
## Was in diesem Projekt immer wieder schiefging
|
||||||
|
|
||||||
Wer diese fünf kennt, spart sich die Suche:
|
Wer diese fünf kennt, spart sich die Suche:
|
||||||
|
|||||||
+11
-1
@@ -1,11 +1,13 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
Stand: 13.09.2026, Fassung **1.4.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
Stand: 13.09.2026, Fassung **1.4.3**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||||
|
|
||||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||||
|
|
||||||
**Für den Livegang vorbereitet (13.09.2026):** `docs/LIVEGANG.md` trennt, was geprüft ist, von dem, was nur auf dem echten Hosting geprüft werden kann, und nennt die Reihenfolge am Umzugstag. **14 Weiterleitungen** von den alten WordPress-Adressen auf die neuen sind angelegt und beantwortet (`/productsolutions` → `/projekte`, die gekürzten Beitragsadressen, `/category/allgemein`); ohne sie hätte jede bei Google stehende Adresse nach dem Umzug mit 404 geantwortet. In der Datenschutzerklärung wurde der Abschnitt über Google Fonts an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server) und die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist – eine Erklärung, die Datenflüsse beschreibt, die es nicht gibt, ist so falsch wie eine, die welche verschweigt. **Nicht geprüft und nicht prüfbar von hier: der E-Mail-Versand.**
|
**Für den Livegang vorbereitet (13.09.2026):** `docs/LIVEGANG.md` trennt, was geprüft ist, von dem, was nur auf dem echten Hosting geprüft werden kann, und nennt die Reihenfolge am Umzugstag. **14 Weiterleitungen** von den alten WordPress-Adressen auf die neuen sind angelegt und beantwortet (`/productsolutions` → `/projekte`, die gekürzten Beitragsadressen, `/category/allgemein`); ohne sie hätte jede bei Google stehende Adresse nach dem Umzug mit 404 geantwortet. In der Datenschutzerklärung wurde der Abschnitt über Google Fonts an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server) und die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist – eine Erklärung, die Datenflüsse beschreibt, die es nicht gibt, ist so falsch wie eine, die welche verschweigt. **Nicht geprüft und nicht prüfbar von hier: der E-Mail-Versand.**
|
||||||
|
|
||||||
|
**Verwaltung und Updateweg (1.4.3, 13.09.2026):** Die Farbfelder im Designbereich haben einen **Farbwähler** samt Palettenauswahl; das Textfeld bleibt maßgeblich, damit `@akzent` ein Verweis bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). Die Verwaltung ist **auf dem Telefon bedienbar**: das Menü ist unter 760 px ein zugeklapptes `<details>`, das den aktuellen Bereich nennt, mit 44 px hohen Zielen – ohne Skript. Die zwölf Navigationssymbole sind **eigene SVG-Pfade** statt Unicode-Zeichen, die ein Telefon zu bunten Emoji macht (ADR 067). Und es gibt ein **Update ohne Git**: `ArchiveUpdate` lädt das Releasearchiv, vergleicht die Prüfsumme, sichert, ersetzt die Dateien und führt die Migrationen in einer zweiten Anfrage aus – der einzige Updateweg auf geteiltem Hosting (ADR 068, `docs/UPDATE.md`).
|
||||||
|
|
||||||
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
|
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
|
||||||
|
|
||||||
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
|
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
|
||||||
@@ -37,6 +39,12 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
|||||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||||
|
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
|
||||||
|
- **Update aus dem Releasearchiv** (1.4.3, ADR 068): der Weg für Installationen ohne Git – bisher stand dort nur „Dieses Verzeichnis ist keine Git-Arbeitskopie". Zwei Knöpfe im Bildschirm „System": der erste lädt, prüft die Prüfsumme, sichert und ersetzt die Dateien, der zweite führt die Migrationen aus – in einer eigenen Anfrage, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. `var/`, `.env`, hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt, `public/` wird dazu Datei für Datei zusammengeführt. Der alte Stand liegt danach unter `var/update/`. Geprüft mit acht Tests gegen eine Releaseseite im Speicher (`tests/ArchiveUpdateTest.php`), einer davon hält die Ersetzungsliste gegen die Mitnahmeliste des Releaseskripts.
|
||||||
|
- **Eigene Symbole in der Verwaltung** (1.4.3, ADR 067): zwölf SVG-Pfade statt Unicode-Zeichen, die auf dem Telefon als Emoji in fremdem Stil erscheinen. Ein Test findet Emoji-Zeichen in der Navigationsvorlage und zwei Einträge mit demselben Pfad – beides war vorhanden.
|
||||||
|
- **Verwaltung auf dem Telefon** (1.4.3, ADR 067): das Menü klappt unter 760 px zu und nennt in der Zusammenfassung den Bereich, in dem man steht; Ziele 44 px hoch, kein Skript, kein seitliches Scrollen. Geprüft bei 390 px.
|
||||||
|
- **Farbwähler im Designbereich** (1.4.3, ADR 066): Farbfeld und Palettenauswahl neben dem Textfeld, das maßgeblich bleibt – ein `<input type="color">` allein hätte `@akzent` in einen festen Wert verwandelt und die Verbindung zur Palette gekappt. Ohne JavaScript bleibt das Textfeld.
|
||||||
|
- **Behoben in 1.4.2: die Systemprüfung nennt jetzt die SQLite-Fassung.** Auf dem Hosting scheiterte die Installation im dritten Schritt mit „near \"(\": syntax error" – dort läuft SQLite in einer Fassung vor 3.16, und Doctrine liest das Schema mit Tabellenfunktionen (`pragma_table_info(...)`), die es erst seit 3.16 gibt. Die Prüfung nennt die gefundene Fassung vor der Wahl der Datenbank, und der Datenbankschritt bietet SQLite gar nicht erst an, wenn sie zu alt ist – mit dem Hinweis auf MySQL. Eine Voraussetzung, die erst mitten in der Installation auffällt, ist keine Prüfung wert.
|
||||||
- **Behoben in 1.4.1: dem Releasearchiv fehlten `migrations.php` und `cli-config.php`.** Auf dem Hosting brach die Installation deshalb im dritten Schritt ab – „The configuration file does not exist", genau dort, wo die Tabellen entstehen sollten. Die beiden Dateien standen weder in der Mitnahme- noch in der Ausschlussliste des Releaseskripts, also fiel niemandem auf, dass sie fehlen. Ein Test ordnet jetzt **jeden** Eintrag im Projektstamm einer der beiden Listen zu; was neu dazukommt und unbedacht bleibt, lässt ihn fehlschlagen. Der Installationsweg wurde anschließend vollständig durchgespielt: 3504 Dateien entpackt, 14 Migrationen ausgeführt, 15 Tabellen angelegt.
|
- **Behoben in 1.4.1: dem Releasearchiv fehlten `migrations.php` und `cli-config.php`.** Auf dem Hosting brach die Installation deshalb im dritten Schritt ab – „The configuration file does not exist", genau dort, wo die Tabellen entstehen sollten. Die beiden Dateien standen weder in der Mitnahme- noch in der Ausschlussliste des Releaseskripts, also fiel niemandem auf, dass sie fehlen. Ein Test ordnet jetzt **jeden** Eintrag im Projektstamm einer der beiden Listen zu; was neu dazukommt und unbedacht bleibt, lässt ihn fehlschlagen. Der Installationsweg wurde anschließend vollständig durchgespielt: 3504 Dateien entpackt, 14 Migrationen ausgeführt, 15 Tabellen angelegt.
|
||||||
- **Fassung 1.4.0 ist veröffentlicht** und der Installationsweg einmal vollständig durchgespielt: `install.php` aus dem Release geladen (anonym, ohne Anmeldung), in ein leeres Verzeichnis gelegt, aufgerufen – es lädt `kantsites-1.4.0.zip` (4,7 MB, 3502 Dateien mit `vendor/`), prüft die SHA-256-Summe, entpackt und löscht sich selbst. Die frische Installation antwortet mit der Systemprüfung unter `/install`. **Das Repository ist dafür öffentlich** – ein privates liefert die Downloadadresse ohne Anmeldung nicht aus.
|
- **Fassung 1.4.0 ist veröffentlicht** und der Installationsweg einmal vollständig durchgespielt: `install.php` aus dem Release geladen (anonym, ohne Anmeldung), in ein leeres Verzeichnis gelegt, aufgerufen – es lädt `kantsites-1.4.0.zip` (4,7 MB, 3502 Dateien mit `vendor/`), prüft die SHA-256-Summe, entpackt und löscht sich selbst. Die frische Installation antwortet mit der Systemprüfung unter `/install`. **Das Repository ist dafür öffentlich** – ein privates liefert die Downloadadresse ohne Anmeldung nicht aus.
|
||||||
- **Die Verwaltung kann umziehen** (ADR 065): `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse – geprüft wird, dass der Name frei ist und keine Seite verdeckt. Der Wert steht in `.env.local` und gilt für Routen, Firewall, Schutzkopfzeilen und Statistik. **In der `robots.txt` wird ein geänderter Name nicht genannt**; beim Standardnamen `admin` bleibt die Zeile, weil diese Adresse ohnehin bekannt ist. Die Systemseite zeigt die aktuelle Adresse an.
|
- **Die Verwaltung kann umziehen** (ADR 065): `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse – geprüft wird, dass der Name frei ist und keine Seite verdeckt. Der Wert steht in `.env.local` und gilt für Routen, Firewall, Schutzkopfzeilen und Statistik. **In der `robots.txt` wird ein geänderter Name nicht genannt**; beim Standardnamen `admin` bleibt die Zeile, weil diese Adresse ohnehin bekannt ist. Die Systemseite zeigt die aktuelle Adresse an.
|
||||||
@@ -178,6 +186,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
|||||||
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
|
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
|
||||||
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
|
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
|
||||||
|
|
||||||
|
**Gemessen am Nachbau selbst (13.09.2026, Fassung 1.4.3):** je drei mobile und drei Desktop-Läufe auf `/`, `/projekte` und `/impressum` im Produktionsmodus, Median. Barrierefreiheit, Best Practices, SEO und agentisches Browsen überall **100**. Leistung: `/projekte` und `/impressum` **100/100**, die Startseite **99 mobil / 97 Desktop** – dort bleibt das blockierende Stylesheet (rund 300 ms erste Anzeige) das Letzte, und das ist zu einem guten Teil der Entwicklungsserver ohne Kompression und Cache-Kopfzeilen. Bericht: `docs/quality/kantit-lighthouse.json`. **Auf dem echten Hosting nachmessen** – dort greifen die Regeln aus `public/.htaccess`.
|
||||||
|
|
||||||
## Bekannte Einschränkungen
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
|
|||||||
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
|
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
|
||||||
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
|
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
|
||||||
|
|
||||||
|
## Der zweite Weg: Update aus dem Releasearchiv
|
||||||
|
|
||||||
|
Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über `install.php` aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". **`src/Ops/ArchiveUpdate.php` ist der Weg für genau diese Installationen** (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.
|
||||||
|
|
||||||
|
Der Ablauf ist derselbe wie oben, nur ohne `git`:
|
||||||
|
|
||||||
|
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
|
||||||
|
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
|
||||||
|
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
|
||||||
|
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
|
||||||
|
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
|
||||||
|
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
|
||||||
|
7. **Zwischenspeicher leeren.**
|
||||||
|
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
|
||||||
|
|
||||||
|
Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.
|
||||||
|
|
||||||
|
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
|
||||||
|
|
||||||
## Ein Release veröffentlichen
|
## Ein Release veröffentlichen
|
||||||
|
|
||||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 0.99,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5208.5,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 0.97,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5277.5,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/projekte",
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5231,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/projekte",
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5272.5,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/impressum",
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5205,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/impressum",
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5179.5,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
}
|
||||||
|
]
|
||||||
+35
-1
@@ -144,7 +144,7 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
|||||||
.nav-item:hover { background: #21362d }
|
.nav-item:hover { background: #21362d }
|
||||||
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
|
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
|
||||||
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
|
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
|
||||||
.nav-icon { width: 19px; text-align: center; font-size: 14px; color: var(--shell-muted); flex-shrink: 0 }
|
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||||
|
|
||||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||||
@@ -654,3 +654,37 @@ details.card > summary:hover { background: #fafbf8 }
|
|||||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||||
.favicon-field select{flex:1}
|
.favicon-field select{flex:1}
|
||||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||||
|
|
||||||
|
.color-field { display: flex; gap: 8px; align-items: center }
|
||||||
|
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||||
|
.color-field-swatch { width: 46px; min-height: 38px; padding: 2px; flex: 0 0 auto; cursor: pointer }
|
||||||
|
.color-field-palette { flex: 0 0 auto; max-width: 40% }
|
||||||
|
/* Die Verwaltung auf dem Telefon.
|
||||||
|
|
||||||
|
Bis 1000px stand die Navigation als waagerechte Wischleiste da - zwei Listen untereinander,
|
||||||
|
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
|
||||||
|
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
|
||||||
|
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
|
||||||
|
.shell-menu > summary { display: none }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
|
||||||
|
.shell-menu { flex: 1 1 100%; order: 3 }
|
||||||
|
.shell-menu > summary {
|
||||||
|
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
|
||||||
|
min-height: 44px; padding: 0 14px; border-radius: 10px;
|
||||||
|
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
|
||||||
|
}
|
||||||
|
.shell-menu > summary::-webkit-details-marker { display: none }
|
||||||
|
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
|
||||||
|
.shell-menu-icon { font-size: 17px }
|
||||||
|
.shell-menu[open] > summary { margin-bottom: 8px }
|
||||||
|
.shell-menu nav { display: block }
|
||||||
|
.shell-menu:not([open]) nav { display: none }
|
||||||
|
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
|
||||||
|
.nav-list { display: grid; gap: 2px }
|
||||||
|
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||||
|
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||||
|
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||||
|
.shell-version { display: none }
|
||||||
|
.shell-user strong { display: none }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Farbfelder in den Verwaltungsformularen.
|
||||||
|
*
|
||||||
|
* Eine Farbe tippt man nicht, man wählt sie. Im Editor gibt es dafür längst ein Bedienelement aus
|
||||||
|
* Farbwähler, Textfeld und Palettenliste - in den Formularen unter „Design" und „Einstellungen"
|
||||||
|
* stand bisher nur ein nacktes Textfeld.
|
||||||
|
*
|
||||||
|
* Das Textfeld bleibt die Wahrheit: Es nimmt einen Wert wie `#1a2b3c` **oder** einen Schlüssel aus
|
||||||
|
* der Palette (`@accent`), und das kann ein Farbwähler nicht. Deshalb wird es nicht ersetzt,
|
||||||
|
* sondern ergänzt. Ohne JavaScript bleibt das Feld genau das, was es vorher war.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const felder = [...document.querySelectorAll('input[data-color]')];
|
||||||
|
if (0 === felder.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feld of felder) {
|
||||||
|
const palette = JSON.parse(feld.dataset.palette || '[]');
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'color-field';
|
||||||
|
feld.parentNode.insertBefore(reihe, feld);
|
||||||
|
|
||||||
|
const waehler = document.createElement('input');
|
||||||
|
waehler.type = 'color';
|
||||||
|
waehler.className = 'color-field-swatch';
|
||||||
|
waehler.setAttribute('aria-label', 'Farbwähler für ' + (feld.labels?.[0]?.textContent.trim() || 'diese Farbe'));
|
||||||
|
// Der Wähler kennt nur Hexwerte. Steht im Feld ein Palettenschlüssel, zeigt er die Farbe,
|
||||||
|
// für die der Schlüssel steht - sonst bliebe er auf Schwarz stehen und sähe falsch aus.
|
||||||
|
const alsHex = wert => {
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(wert)) {
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
const treffer = palette.find(eintrag => '@' + eintrag.key === wert);
|
||||||
|
|
||||||
|
return treffer ? treffer.value : '#000000';
|
||||||
|
};
|
||||||
|
waehler.value = alsHex(feld.value || feld.placeholder || '#000000');
|
||||||
|
waehler.addEventListener('input', () => {
|
||||||
|
feld.value = waehler.value;
|
||||||
|
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
reihe.append(waehler, feld);
|
||||||
|
|
||||||
|
if (palette.length > 0) {
|
||||||
|
const auswahl = document.createElement('select');
|
||||||
|
auswahl.className = 'color-field-palette';
|
||||||
|
auswahl.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||||
|
auswahl.append(new Option('Aus der Palette …', ''));
|
||||||
|
for (const eintrag of palette) {
|
||||||
|
auswahl.append(new Option(`${eintrag.name} (${eintrag.value})`, '@' + eintrag.key));
|
||||||
|
}
|
||||||
|
auswahl.value = palette.some(eintrag => '@' + eintrag.key === feld.value) ? feld.value : '';
|
||||||
|
auswahl.addEventListener('change', () => {
|
||||||
|
if ('' === auswahl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feld.value = auswahl.value;
|
||||||
|
waehler.value = alsHex(auswahl.value);
|
||||||
|
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
reihe.append(auswahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tippt jemand von Hand, folgt der Wähler mit - solange der Wert einer ist, den er kennt.
|
||||||
|
feld.addEventListener('input', () => {
|
||||||
|
const hex = alsHex(feld.value);
|
||||||
|
if (waehler.value !== hex) {
|
||||||
|
waehler.value = hex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -14,6 +14,13 @@ final class MediaLibrary
|
|||||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||||
public const WIDTHS = [480, 960, 1600, 2400];
|
public const WIDTHS = [480, 960, 1600, 2400];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||||
|
*
|
||||||
|
* @var array<string, ?array>
|
||||||
|
*/
|
||||||
|
private array $gemerkt = [];
|
||||||
|
|
||||||
public function __construct(private Connection $db, private string $directory)
|
public function __construct(private Connection $db, private string $directory)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -53,11 +60,22 @@ final class MediaLibrary
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
|
||||||
|
*
|
||||||
|
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
|
||||||
|
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
|
||||||
|
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
|
||||||
|
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
|
||||||
|
*/
|
||||||
public function find(string $id): ?array
|
public function find(string $id): ?array
|
||||||
{
|
{
|
||||||
|
if (array_key_exists($id, $this->gemerkt)) {
|
||||||
|
return $this->gemerkt[$id];
|
||||||
|
}
|
||||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||||
|
|
||||||
return $row ? $this->present($row) : null;
|
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,6 +181,7 @@ final class MediaLibrary
|
|||||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
||||||
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||||
$this->db->insert('media', $row);
|
$this->db->insert('media', $row);
|
||||||
|
$this->gemerkt = [];
|
||||||
|
|
||||||
return $this->present($row);
|
return $this->present($row);
|
||||||
} catch (\Throwable $error) {
|
} catch (\Throwable $error) {
|
||||||
@@ -183,6 +202,7 @@ final class MediaLibrary
|
|||||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
||||||
}
|
}
|
||||||
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
||||||
|
unset($this->gemerkt[$id]);
|
||||||
if (1 !== $count) {
|
if (1 !== $count) {
|
||||||
throw new StaleWrite();
|
throw new StaleWrite();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ final class StyleCompiler
|
|||||||
{
|
{
|
||||||
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
|
||||||
|
*
|
||||||
|
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
|
||||||
|
* holte ein Vorabverweis ein zweites Bild.
|
||||||
|
*/
|
||||||
|
public const PRELOAD_DEVICES = [
|
||||||
|
'desktop' => '(min-width:1025px)',
|
||||||
|
'tablet' => '(min-width:768px) and (max-width:1024px)',
|
||||||
|
'mobile' => '(max-width:767px)',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(private ?MediaLibrary $media = null)
|
public function __construct(private ?MediaLibrary $media = null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -226,7 +238,7 @@ final class StyleCompiler
|
|||||||
if (null !== $item && [] !== $item['variants']) {
|
if (null !== $item && [] !== $item['variants']) {
|
||||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||||
// srcset, but the rules are already written per device, so the device decides.
|
// srcset, but the rules are already written per device, so the device decides.
|
||||||
$largest = $this->variantFor($item['variants'], $device);
|
$largest = self::variantFor($item['variants'], $device);
|
||||||
// The URL comes from the media library, never from the document.
|
// The URL comes from the media library, never from the document.
|
||||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||||
}
|
}
|
||||||
@@ -356,7 +368,17 @@ final class StyleCompiler
|
|||||||
return $style;
|
return $style;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function variantFor(array $variants, string $device): array
|
/**
|
||||||
|
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
|
||||||
|
*
|
||||||
|
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
|
||||||
|
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* @param list<array{src: string, width: int}> $variants
|
||||||
|
*
|
||||||
|
* @return array{src: string, width: int}
|
||||||
|
*/
|
||||||
|
public static function variantFor(array $variants, string $device): array
|
||||||
{
|
{
|
||||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||||
$chosen = $variants[0];
|
$chosen = $variants[0];
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ final class InstallController extends AbstractController
|
|||||||
'error' => $error,
|
'error' => $error,
|
||||||
'values' => $request->request->all(),
|
'values' => $request->request->all(),
|
||||||
'mysql' => \extension_loaded('pdo_mysql'),
|
'mysql' => \extension_loaded('pdo_mysql'),
|
||||||
'sqlite' => \extension_loaded('pdo_sqlite'),
|
// SQLite nur, wenn die Fassung die Tabellenfunktionen kennt, mit denen Doctrine das
|
||||||
|
// Schema liest - sonst scheitert erst der naechste Schritt, und zwar unverstaendlich.
|
||||||
|
'sqlite' => \extension_loaded('pdo_sqlite') && null !== $this->installation->sqliteVersion()
|
||||||
|
&& version_compare((string) $this->installation->sqliteVersion(), Installation::MIN_SQLITE, '>='),
|
||||||
|
'sqliteVersion' => $this->installation->sqliteVersion(),
|
||||||
|
'minSqlite' => Installation::MIN_SQLITE,
|
||||||
...$this->token('datenbank'),
|
...$this->token('datenbank'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ final class PageController extends AbstractController
|
|||||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||||
|
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||||
|
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||||
|
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||||
'pageStyles' => $styles,
|
'pageStyles' => $styles,
|
||||||
'googleFonts' => $googleFonts,
|
'googleFonts' => $googleFonts,
|
||||||
'localFonts' => $localFonts,
|
'localFonts' => $localFonts,
|
||||||
@@ -539,6 +542,49 @@ final class PageController extends AbstractController
|
|||||||
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
|
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
|
||||||
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
|
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
|
||||||
|
*
|
||||||
|
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
|
||||||
|
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
|
||||||
|
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
|
||||||
|
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
|
||||||
|
*
|
||||||
|
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
|
||||||
|
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
|
||||||
|
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
|
||||||
|
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
|
||||||
|
*
|
||||||
|
* @return list<array{src: string, media: string}>
|
||||||
|
*/
|
||||||
|
private function heroBackground(?array $header, array $document): array
|
||||||
|
{
|
||||||
|
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
|
||||||
|
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||||
|
foreach ([$header, $document] as $kandidat) {
|
||||||
|
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||||
|
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||||
|
if (!is_string($id) || '' === $id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$item = $this->media->find($id);
|
||||||
|
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$verweise = [];
|
||||||
|
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||||
|
$verweise[] = [
|
||||||
|
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||||
|
'media' => $media,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $verweise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
private function firstImage(array ...$documents): ?string
|
private function firstImage(array ...$documents): ?string
|
||||||
{
|
{
|
||||||
foreach ($documents as $document) {
|
foreach ($documents as $document) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Content\MediaLibrary;
|
|||||||
use App\Content\PageRepository;
|
use App\Content\PageRepository;
|
||||||
use App\Content\RedirectRepository;
|
use App\Content\RedirectRepository;
|
||||||
use App\Content\SiteSettings;
|
use App\Content\SiteSettings;
|
||||||
|
use App\Content\Theme;
|
||||||
use App\Domain\InvalidPage;
|
use App\Domain\InvalidPage;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -16,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
|
|
||||||
final class SettingsController extends AbstractController
|
final class SettingsController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media)
|
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ final class SettingsController extends AbstractController
|
|||||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||||
'images' => $this->media->page(1, false, 96)['items'],
|
'images' => $this->media->page(1, false, 96)['items'],
|
||||||
|
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||||
|
'palette' => $this->theme->load()['colors'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Domain\InvalidPage;
|
use App\Domain\InvalidPage;
|
||||||
|
use App\Ops\ArchiveUpdate;
|
||||||
use App\Ops\Backup;
|
use App\Ops\Backup;
|
||||||
|
use App\Ops\Installation;
|
||||||
use App\Ops\Updater;
|
use App\Ops\Updater;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
*/
|
*/
|
||||||
final class SystemController extends AbstractController
|
final class SystemController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private Updater $updater, private Backup $backup, private string $adminPath)
|
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +41,50 @@ final class SystemController extends AbstractController
|
|||||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||||
'backups' => $this->backup->all(),
|
'backups' => $this->backup->all(),
|
||||||
'adminPath' => $this->adminPath,
|
'adminPath' => $this->adminPath,
|
||||||
|
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||||||
|
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||||||
|
'archivePending' => $this->archive->pending(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
|
||||||
|
*
|
||||||
|
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
|
||||||
|
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
|
||||||
|
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
|
||||||
|
*/
|
||||||
|
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
|
||||||
|
public function archive(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);
|
||||||
|
if ($request->request->getBoolean('finish')) {
|
||||||
|
$ergebnis = $this->archive->finish($this->installation);
|
||||||
|
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||||
|
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||||
|
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
|
||||||
|
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_system');
|
||||||
|
}
|
||||||
|
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||||||
|
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||||
|
if ($ergebnis['migrate'] ?? false) {
|
||||||
|
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_system');
|
||||||
|
}
|
||||||
|
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||||
|
? 'Es war nichts einzuspielen.'
|
||||||
|
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_system');
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||||
public function update(Request $request): Response
|
public function update(Request $request): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Ops;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Update für Installationen ohne Git.
|
||||||
|
*
|
||||||
|
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
|
||||||
|
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
|
||||||
|
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
|
||||||
|
* genau der Fall, für den dieses CMS gedacht ist.
|
||||||
|
*
|
||||||
|
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
|
||||||
|
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
|
||||||
|
* einer Installation trennen:
|
||||||
|
*
|
||||||
|
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
|
||||||
|
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
|
||||||
|
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
|
||||||
|
* `var/update/` und lässt sich zurückschieben.
|
||||||
|
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
|
||||||
|
* niemand zu sehen bekommen.
|
||||||
|
*/
|
||||||
|
final class ArchiveUpdate
|
||||||
|
{
|
||||||
|
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
|
||||||
|
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
|
||||||
|
|
||||||
|
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
|
||||||
|
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Marke zwischen den beiden Schritten.
|
||||||
|
*
|
||||||
|
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
|
||||||
|
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
|
||||||
|
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
|
||||||
|
*/
|
||||||
|
private const MARKE = 'var/update/offen.json';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $root,
|
||||||
|
private Updater $updater,
|
||||||
|
private Backup $backup,
|
||||||
|
private Download $download,
|
||||||
|
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
|
||||||
|
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
|
||||||
|
*
|
||||||
|
* @return array{version: string, url: string, checksum: ?string}|null
|
||||||
|
*/
|
||||||
|
public function latest(): ?array
|
||||||
|
{
|
||||||
|
$antwort = $this->download->get($this->apiBase.'/releases/latest');
|
||||||
|
if (null === $antwort) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tag = (string) ($daten['tag_name'] ?? '');
|
||||||
|
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$version = $treffer[1];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $version,
|
||||||
|
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
|
||||||
|
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es etwas Neueres als das, was hier läuft? */
|
||||||
|
public function updateAvailable(): ?string
|
||||||
|
{
|
||||||
|
$neueste = $this->latest();
|
||||||
|
|
||||||
|
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
|
||||||
|
? $neueste['version']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt die angegebene (oder die neueste) Fassung ein.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||||
|
*/
|
||||||
|
public function run(?string $version = null): array
|
||||||
|
{
|
||||||
|
$schritte = [];
|
||||||
|
$melden = static function (array &$schritte, string $label, bool $ok, string $detail = ''): bool {
|
||||||
|
$schritte[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
$neueste = $this->latest();
|
||||||
|
if (null === $neueste) {
|
||||||
|
$melden($schritte, 'Fassung ermitteln', false, 'Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$ziel = $version ?? $neueste['version'];
|
||||||
|
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
|
||||||
|
if (version_compare($ziel, $this->updater->version(), '<=')) {
|
||||||
|
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
|
||||||
|
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
|
||||||
|
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
|
||||||
|
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
|
||||||
|
try {
|
||||||
|
$sicherung = $this->backup->create('vor dem Update');
|
||||||
|
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
|
||||||
|
} catch (\Throwable $fehler) {
|
||||||
|
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
|
||||||
|
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
|
||||||
|
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
||||||
|
if (null === $inhalt || '' === $inhalt) {
|
||||||
|
$melden($schritte, 'Archiv laden', false, 'Das Archiv ließ sich nicht herunterladen. Darf dieser Server nach außen verbinden?');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
file_put_contents($archiv, $inhalt);
|
||||||
|
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
|
||||||
|
|
||||||
|
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
|
||||||
|
if (null === $erwartet) {
|
||||||
|
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$tatsaechlich = hash_file('sha256', $archiv);
|
||||||
|
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
|
||||||
|
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
|
||||||
|
|
||||||
|
$entpackt = $arbeit.'/neu';
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
|
||||||
|
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$quelle = $entpackt.'/kantsites-'.$ziel;
|
||||||
|
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
|
||||||
|
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
|
||||||
|
|
||||||
|
$this->updater->maintenance(true);
|
||||||
|
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
|
||||||
|
|
||||||
|
$alt = $arbeit.'/alt';
|
||||||
|
@mkdir($alt, 0o775, true);
|
||||||
|
$ersetzt = 0;
|
||||||
|
try {
|
||||||
|
foreach (self::ERSETZEN as $eintrag) {
|
||||||
|
$ziant = $this->root.'/'.$eintrag;
|
||||||
|
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
|
||||||
|
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
|
||||||
|
}
|
||||||
|
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
|
||||||
|
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
|
||||||
|
}
|
||||||
|
++$ersetzt;
|
||||||
|
}
|
||||||
|
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
|
||||||
|
// Betreiber und wären sonst weg.
|
||||||
|
$this->mergePublic($quelle.'/public', $this->root.'/public');
|
||||||
|
} catch (\Throwable $fehler) {
|
||||||
|
foreach (self::ERSETZEN as $eintrag) {
|
||||||
|
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
|
||||||
|
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
|
||||||
|
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
|
||||||
|
$this->updater->maintenance(false);
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
|
||||||
|
|
||||||
|
// Der Zwischenspeicher gehört zum alten Stand.
|
||||||
|
$this->removeDirectory($this->root.'/var/cache');
|
||||||
|
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
||||||
|
|
||||||
|
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
|
||||||
|
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
|
||||||
|
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
|
||||||
|
// Wartungszustand bleibt so lange an.
|
||||||
|
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
|
||||||
|
'fassung' => $ziel,
|
||||||
|
'seit' => gmdate('c'),
|
||||||
|
'alt' => basename($arbeit),
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
|
||||||
|
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
|
||||||
|
$ziel,
|
||||||
|
));
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
||||||
|
*
|
||||||
|
* @return array{fassung: string, seit: string, alt: string}|null
|
||||||
|
*/
|
||||||
|
public function pending(): ?array
|
||||||
|
{
|
||||||
|
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
|
||||||
|
if (!is_string($inhalt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
|
||||||
|
*
|
||||||
|
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||||
|
*/
|
||||||
|
public function finish(Installation $installation): array
|
||||||
|
{
|
||||||
|
$schritte = [];
|
||||||
|
try {
|
||||||
|
$namen = $installation->migrate();
|
||||||
|
$schritte[] = ['label' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||||
|
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||||
|
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||||
|
} catch (\Throwable $fehler) {
|
||||||
|
$schritte[] = ['label' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($this->root.'/'.self::MARKE);
|
||||||
|
$this->updater->maintenance(false);
|
||||||
|
$schritte[] = ['label' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||||
|
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||||
|
$this->updater->version(),
|
||||||
|
)];
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $schritte];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
|
||||||
|
private function mergePublic(string $von, string $nach): void
|
||||||
|
{
|
||||||
|
if (!is_dir($von)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (scandir($von) ?: [] as $eintrag) {
|
||||||
|
if ('.' === $eintrag || '..' === $eintrag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$quelle = $von.'/'.$eintrag;
|
||||||
|
$ziel = $nach.'/'.$eintrag;
|
||||||
|
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
|
||||||
|
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_dir($quelle)) {
|
||||||
|
@mkdir($ziel, 0o775, true);
|
||||||
|
$this->mergePublic($quelle, $ziel);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
@copy($quelle, $ziel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
|
||||||
|
private function checksumOf(string $url): ?string
|
||||||
|
{
|
||||||
|
$inhalt = $this->download->get($url);
|
||||||
|
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $treffer[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $pfad): void
|
||||||
|
{
|
||||||
|
if (!is_dir($pfad)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||||
|
if ('.' === $eintrag || '..' === $eintrag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$voll = $pfad.'/'.$eintrag;
|
||||||
|
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
||||||
|
}
|
||||||
|
@rmdir($pfad);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Ops;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Datei über HTTPS - über curl, ersatzweise über den Dateizeiger.
|
||||||
|
*
|
||||||
|
* Zwei Wege, weil auf einem Webhosting mal das eine da ist und mal das andere: curl fehlt auf
|
||||||
|
* manchen Paketen, `allow_url_fopen` ist auf anderen abgeschaltet. Dieselbe Doppelung steht im
|
||||||
|
* Installer und beim Laden der Schriften.
|
||||||
|
*
|
||||||
|
* Eine eigene Klasse ist das aus zwei Gründen: Die Prüfung des Zertifikats und die Sperre für
|
||||||
|
* alles außer HTTPS stehen an genau einer Stelle - und der Updater lässt sich prüfen, ohne dass
|
||||||
|
* ein Test ins Netz geht.
|
||||||
|
*/
|
||||||
|
class Download
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int $connectTimeout = 15,
|
||||||
|
private int $timeout = 900,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
|
||||||
|
public function get(string $url): ?string
|
||||||
|
{
|
||||||
|
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
|
||||||
|
// kommt, kann unterwegs ausgetauscht worden sein.
|
||||||
|
if (!str_starts_with($url, 'https://')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$curl = curl_init($url);
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 3,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_USERAGENT => 'KantSites-Updater',
|
||||||
|
]);
|
||||||
|
$antwort = curl_exec($curl);
|
||||||
|
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return is_string($antwort) && 200 === $status ? $antwort : null;
|
||||||
|
}
|
||||||
|
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||||
|
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||||
|
]);
|
||||||
|
$antwort = @file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
return is_string($antwort) ? $antwort : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,25 @@ final class Installation
|
|||||||
* Every failing check names the file or path that has to change. A check that is merely
|
* Every failing check names the file or path that has to change. A check that is merely
|
||||||
* advisable is marked as such, so a missing `proc_open` does not look like a reason to stop.
|
* advisable is marked as such, so a missing `proc_open` does not look like a reason to stop.
|
||||||
*/
|
*/
|
||||||
|
/** Ab dieser Fassung kennt SQLite die Tabellenfunktionen, mit denen Doctrine das Schema liest. */
|
||||||
|
public const MIN_SQLITE = '3.16.0';
|
||||||
|
|
||||||
|
/** Die Fassung der eingebauten SQLite-Bibliothek, oder null wenn es keine gibt. */
|
||||||
|
public function sqliteVersion(): ?string
|
||||||
|
{
|
||||||
|
if (!extension_loaded('pdo_sqlite')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$version = (new \PDO('sqlite::memory:'))->query('SELECT sqlite_version()')?->fetchColumn();
|
||||||
|
} catch (\PDOException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($version) ? $version : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function checks(): array
|
public function checks(): array
|
||||||
{
|
{
|
||||||
$checks = [];
|
$checks = [];
|
||||||
@@ -83,6 +102,22 @@ final class Installation
|
|||||||
$drivers = array_values(array_intersect(['pdo_sqlite', 'pdo_mysql'], get_loaded_extensions()));
|
$drivers = array_values(array_intersect(['pdo_sqlite', 'pdo_mysql'], get_loaded_extensions()));
|
||||||
$checks[] = $this->check('Datenbanktreiber', [] !== $drivers, 'Weder pdo_sqlite noch pdo_mysql ist vorhanden. Ohne einen von beiden gibt es keine Datenbank.', true, implode(', ', $drivers));
|
$checks[] = $this->check('Datenbanktreiber', [] !== $drivers, 'Weder pdo_sqlite noch pdo_mysql ist vorhanden. Ohne einen von beiden gibt es keine Datenbank.', true, implode(', ', $drivers));
|
||||||
|
|
||||||
|
// Die Fassung von SQLite, und zwar vor der Wahl der Datenbank. Doctrine liest das Schema
|
||||||
|
// mit Tabellenfunktionen wie `pragma_table_info(...)`; die gibt es erst seit SQLite 3.16
|
||||||
|
// (Januar 2017). Auf einem Hosting mit aelterem SQLite bricht sonst mitten in der
|
||||||
|
// Installation eine Abfrage mit `near "(": syntax error` ab - eine Meldung, die niemandem
|
||||||
|
// sagt, was zu tun ist. Gefunden auf einem echten Hosting, nicht hier.
|
||||||
|
$sqlite = $this->sqliteVersion();
|
||||||
|
if (null !== $sqlite) {
|
||||||
|
$checks[] = $this->check(
|
||||||
|
'SQLite-Fassung',
|
||||||
|
version_compare($sqlite, self::MIN_SQLITE, '>='),
|
||||||
|
sprintf('Gefunden: %s. Für eine SQLite-Datenbank wird mindestens %s gebraucht. Diese Installation läuft trotzdem – aber mit MySQL statt SQLite; eine Datenbank legt man beim Hoster im Kundenmenü an.', $sqlite, self::MIN_SQLITE),
|
||||||
|
false,
|
||||||
|
$sqlite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$webp = extension_loaded('gd') && (gd_info()['WebP Support'] ?? false);
|
$webp = extension_loaded('gd') && (gd_info()['WebP Support'] ?? false);
|
||||||
$checks[] = $this->check('GD mit WebP', $webp, 'Bilder werden beim Hochladen in WebP umgewandelt. Ohne GD mit WebP-Unterstützung schlägt jeder Upload fehl. Der Rest der Website funktioniert.', false);
|
$checks[] = $this->check('GD mit WebP', $webp, 'Bilder werden beim Hochladen in WebP umgewandelt. Ohne GD mit WebP-Unterstützung schlägt jeder Upload fehl. Der Rest der Website funktioniert.', false);
|
||||||
$checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false);
|
$checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
|
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
|
||||||
{% include 'favicon.html.twig' %}
|
{% include 'favicon.html.twig' %}
|
||||||
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
|
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
|
||||||
|
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
|
||||||
{% block styles %}{% endblock %}
|
{% block styles %}{% endblock %}
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{# Die Symbole der Verwaltung, als Inline-SVG.
|
||||||
|
|
||||||
|
Vorher standen hier Unicode-Zeichen (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie wie Symbole
|
||||||
|
aus, auf einem Telefon ersetzt das Betriebssystem sie durch Emoji - buntes Zahnrad, buntes
|
||||||
|
Briefsymbol, jedes in einem anderen Stil. Eigene Pfade lösen das endgültig: eine Linienstärke,
|
||||||
|
eine Farbe (currentColor), kein zusätzlicher Request, keine Schriftdatei. #}
|
||||||
|
{% set paths = {
|
||||||
|
pages: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6M9 16h6',
|
||||||
|
partials: 'M4 5h16v14H4Zm0 5h16M9 10v9',
|
||||||
|
menus: 'M4 6h16M4 12h16M4 18h10',
|
||||||
|
media: 'M4 5h16v14H4Zm0 10 4-4 4 4 3-3 5 5M8.5 9.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
|
||||||
|
forms: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||||
|
statistics: 'M4 20h16M7 20v-6m5 6V6m5 14v-9',
|
||||||
|
theme: 'M12 3a9 9 0 1 0 0 18Zm0 0a9 9 0 0 1 0 18',
|
||||||
|
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8-3a8 8 0 0 0-.2-1.7l2-1.5-2-3.5-2.3 1a8 8 0 0 0-3-1.7L14 2h-4l-.5 2.6a8 8 0 0 0-3 1.7l-2.3-1-2 3.5 2 1.5a8 8 0 0 0 0 3.4l-2 1.5 2 3.5 2.3-1a8 8 0 0 0 3 1.7L10 22h4l.5-2.6a8 8 0 0 0 3-1.7l2.3 1 2-3.5-2-1.5c.1-.6.2-1.1.2-1.7Z',
|
||||||
|
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||||
|
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||||
|
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
|
||||||
|
} %}
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name]|default(paths.pages) }}"/></svg>
|
||||||
@@ -109,11 +109,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="scrollTopColor">Farbe</label>
|
<label for="scrollTopColor">Farbe</label>
|
||||||
<input id="scrollTopColor" name="scrollTopColor" value="{{ settings.scrollTopColor }}" placeholder="@accent">
|
<input id="scrollTopColor" name="scrollTopColor" value="{{ settings.scrollTopColor }}" placeholder="@accent" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="scrollTopInk">Farbe des Pfeils</label>
|
<label for="scrollTopInk">Farbe des Pfeils</label>
|
||||||
<input id="scrollTopInk" name="scrollTopInk" value="{{ settings.scrollTopInk }}" placeholder="#ffffff">
|
<input id="scrollTopInk" name="scrollTopInk" value="{{ settings.scrollTopInk }}" placeholder="#ffffff" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="scrollTopLabel">Beschriftung für Vorlesesoftware</label>
|
<label for="scrollTopLabel">Beschriftung für Vorlesesoftware</label>
|
||||||
|
|||||||
@@ -5,31 +5,46 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<header class="shell-nav">
|
<header class="shell-nav">
|
||||||
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
|
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
|
||||||
|
{# Auf dem Telefon ein Klappmenue, am Schreibtisch die gewohnte Spalte. Ein details-Element
|
||||||
|
kann beides, ohne eine Zeile JavaScript: die Zusammenfassung wird auf grossen Bildschirmen
|
||||||
|
ausgeblendet und der Inhalt dauerhaft gezeigt. Zwoelf Eintraege in einer Wischleiste zu
|
||||||
|
verstecken war die schlechtere Loesung - man sieht nie, was man nicht trifft. #}
|
||||||
|
<details class="shell-menu">
|
||||||
|
<summary>
|
||||||
|
<svg class="shell-menu-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||||
|
<span class="shell-menu-label">{{ {
|
||||||
|
'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
|
||||||
|
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
|
||||||
|
'settings': 'Einstellungen', 'system': 'System', 'users': 'Benutzer',
|
||||||
|
'account': 'Mein Konto'
|
||||||
|
}[nav|default('')]|default('Verwaltung') }}</span>
|
||||||
|
</summary>
|
||||||
<nav aria-label="Verwaltung">
|
<nav aria-label="Verwaltung">
|
||||||
<p class="nav-label" id="nav-content">INHALT</p>
|
<p class="nav-label" id="nav-content">INHALT</p>
|
||||||
<ul class="nav-list" aria-labelledby="nav-content">
|
<ul class="nav-list" aria-labelledby="nav-content">
|
||||||
{% set entries = [
|
{% set entries = [
|
||||||
{id: 'pages', route: 'admin_pages', icon: '▤', label: 'Seiten'},
|
{id: 'pages', route: 'admin_pages', icon: 'pages', label: 'Seiten'},
|
||||||
{id: 'partials', route: 'admin_partials', icon: '◫', label: 'Bereiche'},
|
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche'},
|
||||||
{id: 'menus', route: 'admin_menus', icon: '☰', label: 'Menüs'},
|
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs'},
|
||||||
{id: 'media', route: 'admin_media', icon: '▩', label: 'Mediathek'},
|
{id: 'media', route: 'admin_media', icon: 'media', label: 'Mediathek'},
|
||||||
{id: 'forms', route: 'admin_forms', icon: '✉', label: 'Formulare'},
|
{id: 'forms', route: 'admin_forms', icon: 'forms', label: 'Formulare'},
|
||||||
{id: 'statistics', route: 'admin_statistics', icon: '▁', label: 'Statistik'}
|
{id: 'statistics', route: 'admin_statistics', icon: 'statistics', label: 'Statistik'}
|
||||||
] %}
|
] %}
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">{{ entry.icon }}</span>{{ entry.label }}</a></li>
|
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: entry.icon} only %}{{ entry.label }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="nav-label" id="nav-site">WEBSITE</p>
|
<p class="nav-label" id="nav-site">WEBSITE</p>
|
||||||
<ul class="nav-list" aria-labelledby="nav-site">
|
<ul class="nav-list" aria-labelledby="nav-site">
|
||||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>
|
||||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>
|
||||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⌬</span>System</a></li>{% endif %}
|
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
|
||||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
|
||||||
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
|
||||||
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
<li><a class="nav-item nav-out" href="/beitraege">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
</details>
|
||||||
<div class="shell-foot">
|
<div class="shell-foot">
|
||||||
<p class="shell-version">Fassung {{ kant_version() }}</p>
|
<p class="shell-version">Fassung {{ kant_version() }}</p>
|
||||||
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||||
|
|||||||
@@ -63,6 +63,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Update aus dem Releasearchiv</h2>
|
||||||
|
<span class="muted">{{ state.git ? 'Alternative zum Weg über Git' : 'der Weg ohne Git' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if archivePending %}
|
||||||
|
<p class="notice">Die Dateien der Fassung <strong>{{ archivePending.fassung }}</strong> liegen bereits (seit {{ archivePending.seit|slice(0, 16)|replace({"T": " "}) }} UTC). <strong>Es fehlen noch die Migrationen</strong> – bis dahin bleibt die Website im Wartungszustand.</p>
|
||||||
|
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||||
|
<input type="hidden" name="finish" value="1">
|
||||||
|
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Dieser Weg braucht kein Git: Er lädt das Archiv der neuen Fassung von der Releaseseite, vergleicht die Prüfsumme, sichert Datenbank und Medien, schaltet den Wartungszustand ein und ersetzt die Dateien der Anwendung. <strong>Unberührt bleiben <code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</strong> Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
|
||||||
|
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung – ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.</p>
|
||||||
|
{% if archiveVersion %}
|
||||||
|
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
|
||||||
|
{% elseif asked %}
|
||||||
|
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – das fragt die Releaseseite und dauert einen Moment.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||||
|
<div class="fields">
|
||||||
|
<div>
|
||||||
|
<label for="archive-target">Fassung</label>
|
||||||
|
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+">
|
||||||
|
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -96,15 +96,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="base-color">Textfarbe</label>
|
<label for="base-color">Textfarbe</label>
|
||||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink">
|
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="base-bg">Hintergrundfarbe</label>
|
<label for="base-bg">Hintergrundfarbe</label>
|
||||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper">
|
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="base-link">Linkfarbe</label>
|
<label for="base-link">Linkfarbe</label>
|
||||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent">
|
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<legend>Art der Datenbank</legend>
|
<legend>Art der Datenbank</legend>
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input type="radio" name="driver" value="sqlite" {{ driver != 'mysql' ? 'checked' : '' }} {{ sqlite ? '' : 'disabled' }}>
|
<input type="radio" name="driver" value="sqlite" {{ driver != 'mysql' ? 'checked' : '' }} {{ sqlite ? '' : 'disabled' }}>
|
||||||
<span><strong>SQLite</strong> – eine Datei unter <code>var/</code>. Empfohlen, wenn nichts dagegen spricht.{% if not sqlite %} <em>Auf diesem Server nicht verfügbar.</em>{% endif %}</span>
|
<span><strong>SQLite</strong> – eine Datei unter <code>var/</code>. Empfohlen, wenn nichts dagegen spricht.{% if not sqlite %} <em>{% if sqliteVersion %}Dieser Server hat SQLite {{ sqliteVersion }}; gebraucht wird mindestens {{ minSqlite }}. Nimm MySQL – eine Datenbank legst du beim Hoster im Kundenmenü an.{% else %}Auf diesem Server nicht verfügbar.{% endif %}</em>{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input type="radio" name="driver" value="mysql" {{ driver == 'mysql' ? 'checked' : '' }} {{ mysql ? '' : 'disabled' }}>
|
<input type="radio" name="driver" value="mysql" {{ driver == 'mysql' ? 'checked' : '' }} {{ mysql ? '' : 'disabled' }}>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
{% include 'favicon.html.twig' %}
|
{% include 'favicon.html.twig' %}
|
||||||
{# Ein Bündel statt vier Dateien: jede einzelne hält das Zeichnen der Seite auf. Fehlt es
|
{# Ein Bündel statt vier Dateien: jede einzelne hält das Zeichnen der Seite auf. Fehlt es
|
||||||
(kein Schreibrecht im Ordner), werden die Quellen einzeln geladen - langsamer, aber heil. #}
|
(kein Schreibrecht im Ordner), werden die Quellen einzeln geladen - langsamer, aber heil. #}
|
||||||
|
{# Das grosse Bild ueber der Falz, wenn es der Hintergrund des ersten Abschnitts ist: Es
|
||||||
|
steht sonst nur im Stylesheet und wird erst gefunden, wenn das geparst ist. Genau eine der
|
||||||
|
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
|
||||||
|
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
|
||||||
|
{% endfor %}
|
||||||
{% if styleBundle|default(null) %}
|
{% if styleBundle|default(null) %}
|
||||||
<link rel="stylesheet" href="{{ styleBundle }}">
|
<link rel="stylesheet" href="{{ styleBundle }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use App\Ops\ArchiveUpdate;
|
||||||
|
use App\Ops\Backup;
|
||||||
|
use App\Ops\CommandRunner;
|
||||||
|
use App\Ops\Download;
|
||||||
|
use App\Ops\Updater;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
|
||||||
|
*
|
||||||
|
* Auf geteiltem Hosting gibt es keine Arbeitskopie, also auch kein `git fetch`. Dort wird über
|
||||||
|
* das Archiv installiert und über das Archiv aktualisiert, und das ist der Weg, den die meisten
|
||||||
|
* Installationen dieses CMS nehmen werden. Er war bis hierhin ungeprüft.
|
||||||
|
*
|
||||||
|
* Geprüft wird gegen eine Installation in einem Wegwerfverzeichnis und eine Releaseseite, die im
|
||||||
|
* Speicher liegt (@see ReleaseOnDisk): echte Archive, echte Prüfsummen, echtes Entpacken, echte
|
||||||
|
* Umbenennungen - nur eben ohne Netz. Die Frage, um die es geht, ist nicht „läuft der Download",
|
||||||
|
* sondern: Bleibt stehen, was dem Betreiber gehört?
|
||||||
|
*/
|
||||||
|
final class ArchiveUpdateTest extends TestCase
|
||||||
|
{
|
||||||
|
private const BASE = 'https://releases.example.invalid/download';
|
||||||
|
private const API = 'https://releases.example.invalid/api';
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private Connection $db;
|
||||||
|
private ReleaseOnDisk $releases;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-archiv-'.bin2hex(random_bytes(6));
|
||||||
|
// Eine Installation, wie sie nach dem Entpacken eines Releases aussieht.
|
||||||
|
foreach (['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'var/media', 'public/assets/fonts', 'public/assets/build'] as $verzeichnis) {
|
||||||
|
mkdir($this->root.'/'.$verzeichnis, 0o775, true);
|
||||||
|
}
|
||||||
|
$this->write('VERSION', "1.0.0\n");
|
||||||
|
$this->write('vendor/autoload.php', "<?php // alt\n");
|
||||||
|
$this->write('src/Alt.php', "<?php // wird ersetzt\n");
|
||||||
|
$this->write('composer.json', '{"name":"alt"}');
|
||||||
|
$this->write('composer.lock', '{"packages":[]}');
|
||||||
|
$this->write('migrations.php', "<?php return [];\n");
|
||||||
|
$this->write('cli-config.php', "<?php // alt\n");
|
||||||
|
$this->write('public/index.php', "<?php // alt\n");
|
||||||
|
$this->write('public/assets/site.css', 'body{color:alt}');
|
||||||
|
// Was dem Betreiber gehört - hochgeladene Schriften, das erzeugte Bündel, seine Medien,
|
||||||
|
// seine Datenbank, sein Geheimnis. Nichts davon steht im Archiv.
|
||||||
|
$this->write('public/assets/fonts/eigene.woff2', 'SCHRIFT');
|
||||||
|
$this->write('public/assets/build/bundle.css', 'body{color:erzeugt}');
|
||||||
|
$this->write('var/media/bild.jpg', 'BILD');
|
||||||
|
$this->write('.env', "APP_SECRET=geheim\n");
|
||||||
|
|
||||||
|
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
|
||||||
|
TestSchema::build($this->db);
|
||||||
|
$this->releases = new ReleaseOnDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->db->close();
|
||||||
|
gc_collect_cycles();
|
||||||
|
$this->removeDirectory($this->root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
|
||||||
|
{
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||||
|
|
||||||
|
$ergebnis = $this->updater()->run();
|
||||||
|
|
||||||
|
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||||
|
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||||
|
self::assertSame(
|
||||||
|
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||||
|
array_column($ergebnis['steps'], 'label'),
|
||||||
|
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Anwendung ist die neue.
|
||||||
|
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||||
|
self::assertFileExists($this->root.'/src/Neu.php');
|
||||||
|
self::assertFileDoesNotExist($this->root.'/src/Alt.php', 'src/ wird ersetzt, nicht ergänzt.');
|
||||||
|
self::assertStringContainsString('neu', (string) file_get_contents($this->root.'/public/index.php'));
|
||||||
|
self::assertSame('body{color:neu}', file_get_contents($this->root.'/public/assets/site.css'));
|
||||||
|
|
||||||
|
// Und alles, was dem Betreiber gehört, steht noch da.
|
||||||
|
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
|
||||||
|
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2', 'Schriften aus dem Archiv würden die hochgeladenen überschreiben.');
|
||||||
|
self::assertSame('body{color:erzeugt}', file_get_contents($this->root.'/public/assets/build/bundle.css'));
|
||||||
|
self::assertSame('BILD', file_get_contents($this->root.'/var/media/bild.jpg'));
|
||||||
|
self::assertStringContainsString('APP_SECRET=geheim', (string) file_get_contents($this->root.'/.env'));
|
||||||
|
self::assertFileExists($this->root.'/var/cms.db');
|
||||||
|
|
||||||
|
// Die Website bleibt bis zu den Migrationen im Wartungszustand, und der alte Stand liegt
|
||||||
|
// noch da, falls doch etwas zurückgeschoben werden muss.
|
||||||
|
self::assertFileExists($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||||
|
self::assertCount(1, glob($this->root.'/var/backups/sicherung-*') ?: []);
|
||||||
|
self::assertCount(1, glob($this->root.'/var/update/*/alt/VERSION') ?: []);
|
||||||
|
|
||||||
|
// Der zweite Schritt ist auf der Platte vermerkt, nicht in der Sitzung: fällt sie aus,
|
||||||
|
// bliebe die Website sonst im Wartungszustand und der Knopf wäre weg.
|
||||||
|
$offen = $this->updater()->pending();
|
||||||
|
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
|
||||||
|
self::assertSame('1.1.0', $offen['fassung']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->updater()->pending());
|
||||||
|
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||||
|
$this->updater()->run();
|
||||||
|
|
||||||
|
self::assertNull($this->updater()->pending(), 'Ein Update, das nicht ersetzt hat, lässt nichts offen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnArchiveWithTheWrongChecksumIsNeverUnpacked(): void
|
||||||
|
{
|
||||||
|
// Der Fall, für den die Prüfsumme da ist: unterwegs ausgetauscht oder halb übertragen.
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||||
|
|
||||||
|
$ergebnis = $this->updater()->run();
|
||||||
|
|
||||||
|
self::assertFalse($ergebnis['ok']);
|
||||||
|
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['label']);
|
||||||
|
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
|
||||||
|
self::assertFileExists($this->root.'/src/Alt.php');
|
||||||
|
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnIncompleteArchiveIsRefusedBeforeTheSiteGoesIntoMaintenance(): void
|
||||||
|
{
|
||||||
|
// Ein Archiv ohne vendor/ ist auf einem Hosting ohne Composer wertlos - das muss auffallen,
|
||||||
|
// bevor die halbe Anwendung ersetzt ist.
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0', false));
|
||||||
|
|
||||||
|
$ergebnis = $this->updater()->run();
|
||||||
|
|
||||||
|
self::assertFalse($ergebnis['ok']);
|
||||||
|
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['label']);
|
||||||
|
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
|
||||||
|
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||||
|
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnreachableReleasePageChangesNothingAtAll(): void
|
||||||
|
{
|
||||||
|
// Viele Hoster lassen ausgehende Verbindungen nicht zu. Dann passiert hier gar nichts -
|
||||||
|
// insbesondere wird nicht gesichert, nicht ersetzt und kein Arbeitsordner angelegt.
|
||||||
|
$ergebnis = $this->updater()->run();
|
||||||
|
|
||||||
|
self::assertFalse($ergebnis['ok']);
|
||||||
|
self::assertCount(1, $ergebnis['steps']);
|
||||||
|
self::assertStringContainsString('Releaseseite', $ergebnis['steps'][0]['detail']);
|
||||||
|
self::assertSame([], glob($this->root.'/var/update/*') ?: []);
|
||||||
|
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||||
|
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
|
||||||
|
{
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.0.0', $this->archive('1.0.0'));
|
||||||
|
$updater = $this->updater();
|
||||||
|
|
||||||
|
self::assertNull($updater->updateAvailable());
|
||||||
|
$ergebnis = $updater->run();
|
||||||
|
|
||||||
|
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||||
|
self::assertFalse($ergebnis['migrate'] ?? false);
|
||||||
|
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['label']);
|
||||||
|
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheAvailableVersionIsOnlyReportedWhenItIsNewer(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->updater()->updateAvailable(), 'Ohne Antwort gibt es keine neue Fassung.');
|
||||||
|
|
||||||
|
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||||
|
self::assertSame('1.1.0', $this->updater()->updateAvailable());
|
||||||
|
|
||||||
|
// Etwas, das keine Fassung ist, wird nicht angeboten - sonst lädt der Updater irgendetwas.
|
||||||
|
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'nightly']));
|
||||||
|
self::assertNull($this->updater()->updateAvailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheDownloaderRefusesEverythingThatIsNotHttps(): void
|
||||||
|
{
|
||||||
|
// Kein Netz nötig, um das zu prüfen: die Sperre greift vor dem ersten Byte. Ein Update
|
||||||
|
// über eine unverschlüsselte Verbindung könnte unterwegs ausgetauscht worden sein.
|
||||||
|
$download = new Download();
|
||||||
|
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
|
||||||
|
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
|
||||||
|
{
|
||||||
|
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
|
||||||
|
// Datei neu ins Release, ohne dass sie hier in der Ersetzungsliste steht, bleibt sie beim
|
||||||
|
// Update auf dem alten Stand - und das fällt erst auf, wenn etwas nicht mehr passt.
|
||||||
|
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
|
||||||
|
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
|
||||||
|
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
|
||||||
|
|
||||||
|
$quelle = (string) file_get_contents(__DIR__.'/../src/Ops/ArchiveUpdate.php');
|
||||||
|
preg_match('/ERSETZEN = \[(.+?)\];/s', $quelle, $ersetzt);
|
||||||
|
self::assertNotEmpty($ersetzt, 'Die Ersetzungsliste des Updaters ist nicht auffindbar.');
|
||||||
|
$liste = static fn (string $roh): array => array_filter(array_map(
|
||||||
|
static fn (string $eintrag): string => trim($eintrag, " '\r\n\t"),
|
||||||
|
explode(',', $roh),
|
||||||
|
));
|
||||||
|
|
||||||
|
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
|
||||||
|
// Beiwerk, das eine laufende Installation nicht liest.
|
||||||
|
$anders = ['public', 'README.md', 'LICENSE', '.env.example'];
|
||||||
|
foreach ($liste($mit[1]) as $eintrag) {
|
||||||
|
self::assertContains(
|
||||||
|
$eintrag,
|
||||||
|
[...$liste($ersetzt[1]), ...$anders],
|
||||||
|
sprintf('„%s" liegt im Releasearchiv, wird beim Update aber weder ersetzt noch bewusst ausgelassen.', $eintrag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updater(): ArchiveUpdate
|
||||||
|
{
|
||||||
|
$runner = new CommandRunner();
|
||||||
|
$url = 'sqlite:///'.$this->root.'/var/cms.db';
|
||||||
|
|
||||||
|
return new ArchiveUpdate(
|
||||||
|
$this->root,
|
||||||
|
new Updater($this->root, $url, $runner, $this->db),
|
||||||
|
new Backup($this->root, $url, $this->root.'/var/media', $this->db, $runner),
|
||||||
|
$this->releases,
|
||||||
|
self::BASE,
|
||||||
|
self::API,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut ein Releasearchiv, wie `scripts/build-release.php` es baut: ein Verzeichnis
|
||||||
|
* `kantsites-<Fassung>/` im Zip, darin die Anwendung.
|
||||||
|
*/
|
||||||
|
private function archive(string $version, bool $vollstaendig = true): string
|
||||||
|
{
|
||||||
|
$pfad = $this->root.'/var/release-'.$version.'.zip';
|
||||||
|
@mkdir(dirname($pfad), 0o775, true);
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
self::assertTrue($zip->open($pfad, \ZipArchive::CREATE | \ZipArchive::OVERWRITE));
|
||||||
|
$inhalt = [
|
||||||
|
'VERSION' => $version."\n",
|
||||||
|
'src/Neu.php' => "<?php // neu\n",
|
||||||
|
'bin/console' => "<?php // neu\n",
|
||||||
|
'config/services.yaml' => "services:\n",
|
||||||
|
'templates/base.html.twig' => 'neu',
|
||||||
|
'migrations.php' => "<?php return ['neu'];\n",
|
||||||
|
'cli-config.php' => "<?php // neu\n",
|
||||||
|
'composer.json' => '{"name":"neu"}',
|
||||||
|
'composer.lock' => '{"packages":["neu"]}',
|
||||||
|
'public/index.php' => "<?php // neu\n",
|
||||||
|
'public/assets/site.css' => 'body{color:neu}',
|
||||||
|
// Das Archiv bringt eigene Schriften und ein eigenes Bündel mit - beides darf die
|
||||||
|
// Installation nicht überschreiben.
|
||||||
|
'public/assets/fonts/andere.woff2' => 'FREMD',
|
||||||
|
'public/assets/build/bundle.css' => 'body{color:fremd}',
|
||||||
|
];
|
||||||
|
if ($vollstaendig) {
|
||||||
|
$inhalt['vendor/autoload.php'] = "<?php // neu\n";
|
||||||
|
}
|
||||||
|
foreach ($inhalt as $name => $text) {
|
||||||
|
$zip->addFromString('kantsites-'.$version.'/'.$name, $text);
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$roh = (string) file_get_contents($pfad);
|
||||||
|
unlink($pfad);
|
||||||
|
|
||||||
|
return $roh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(string $relativ, string $inhalt): void
|
||||||
|
{
|
||||||
|
@mkdir(dirname($this->root.'/'.$relativ), 0o775, true);
|
||||||
|
file_put_contents($this->root.'/'.$relativ, $inhalt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{steps: list<array{label: string, ok: bool, detail: string}>} $ergebnis */
|
||||||
|
private function firstFailed(array $ergebnis): array
|
||||||
|
{
|
||||||
|
foreach ($ergebnis['steps'] as $schritt) {
|
||||||
|
if (!$schritt['ok']) {
|
||||||
|
return $schritt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::fail('Kein gescheiterter Schritt: '.$this->explain($ergebnis));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function explain(array $ergebnis): string
|
||||||
|
{
|
||||||
|
return implode("\n", array_map(
|
||||||
|
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['label'], $schritt['detail']),
|
||||||
|
$ergebnis['steps'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $pfad): void
|
||||||
|
{
|
||||||
|
if (!is_dir($pfad)) {
|
||||||
|
@unlink($pfad);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||||
|
if ('.' !== $eintrag && '..' !== $eintrag) {
|
||||||
|
@chmod($pfad.'/'.$eintrag, 0o777);
|
||||||
|
$this->removeDirectory($pfad.'/'.$eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($pfad);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1077,6 +1077,30 @@ final class CmsTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheAdministrationDrawsItsOwnIconsInsteadOfBorrowingCharacters(): void
|
||||||
|
{
|
||||||
|
// Umut: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem
|
||||||
|
// Smartphone kommen sonst Emojis.“ Genau so ist es: ein Telefon ersetzt ein Zeichen wie
|
||||||
|
// das Zahnrad durch ein buntes Emoji in fremdem Stil, und die Navigation sieht aus wie
|
||||||
|
// zusammengewürfelt. Eigene Pfade sind eine Linienstärke, eine Farbe und kein Request.
|
||||||
|
$shell = (string) file_get_contents(__DIR__.'/../templates/admin/shell.html.twig');
|
||||||
|
self::assertDoesNotMatchRegularExpression(
|
||||||
|
'/[\\x{2190}-\\x{2BFF}\\x{1F300}-\\x{1FAFF}]/u',
|
||||||
|
$shell,
|
||||||
|
'In der Navigation steht ein Zeichen, das ein Telefon zum Emoji macht.',
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('admin/icon.html.twig', $shell);
|
||||||
|
|
||||||
|
$icons = (string) file_get_contents(__DIR__.'/../templates/admin/icon.html.twig');
|
||||||
|
// Jedes verwendete Symbol muss es geben - sonst zeichnet die Vorlage stillschweigend das
|
||||||
|
// Ersatzsymbol, und zwei Einträge sehen gleich aus.
|
||||||
|
foreach (['pages', 'partials', 'menus', 'media', 'forms', 'statistics', 'theme', 'settings', 'system', 'users', 'external'] as $name) {
|
||||||
|
self::assertStringContainsString($name.": 'M", $icons, $name.' fehlt in der Symbolsammlung.');
|
||||||
|
}
|
||||||
|
preg_match_all("/'(M[^']+)'/", $icons, $pfade);
|
||||||
|
self::assertSame(count($pfade[1]), count(array_unique($pfade[1])), 'Zwei Symbole tragen denselben Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
public function testNoStylesheetHasADanglingSelector(): void
|
public function testNoStylesheetHasADanglingSelector(): void
|
||||||
{
|
{
|
||||||
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
|
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
|
||||||
@@ -2913,6 +2937,71 @@ final class CmsTest extends TestCase
|
|||||||
self::assertStringNotContainsString('fetchpriority', $second);
|
self::assertStringNotContainsString('fetchpriority', $second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheBackgroundOfTheFirstSectionIsFetchedBeforeTheStylesheetFindsIt(): void
|
||||||
|
{
|
||||||
|
// Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern
|
||||||
|
// der Hintergrund des ersten Abschnitts. Ein Hintergrundbild steht nur im Stylesheet, und
|
||||||
|
// das kommt nach dem HTML - der Browser erfährt also erst vom Bild, wenn er das CSS
|
||||||
|
// geparst hat. Gemessen: 1,3 s statt 0,9 s bis zum größten Element.
|
||||||
|
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||||
|
$datei = tempnam(sys_get_temp_dir(), 'kant-hero-');
|
||||||
|
$limit = ini_get('memory_limit');
|
||||||
|
ini_set('memory_limit', '512M');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bild = imagecreatetruecolor(2600, 1600);
|
||||||
|
imagejpeg($bild, $datei, 80);
|
||||||
|
imagedestroy($bild);
|
||||||
|
$item = $media->upload(new UploadedFile($datei, 'hero.jpg', test: true));
|
||||||
|
|
||||||
|
$version = static fn (Connection $db): int => (int) $db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
|
||||||
|
$ohne = DemoContent::starter('Ohne Hintergrund');
|
||||||
|
$entwurf = $this->pages->saveDraft('page-1', $ohne, $version($this->db));
|
||||||
|
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||||
|
$sparsam = (int) $this->request('/')->headers->get('X-Kant-Queries');
|
||||||
|
|
||||||
|
$mit = DemoContent::starter('Mit Hintergrund');
|
||||||
|
$mit['schemaVersion'] = max(10, $mit['schemaVersion'] ?? 10);
|
||||||
|
$mit['blocks'][0]['style']['desktop']['backgroundImage'] = $item['id'];
|
||||||
|
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||||
|
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||||
|
$antwort = $this->request('/');
|
||||||
|
$html = (string) $antwort->getContent();
|
||||||
|
|
||||||
|
// Drei Verweise, einer je Gerät - und genau einer trifft zu.
|
||||||
|
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', $html, $treffer, PREG_SET_ORDER);
|
||||||
|
self::assertCount(3, $treffer, 'Der Hintergrund des ersten Abschnitts wird nicht vorab geholt.');
|
||||||
|
self::assertSame(
|
||||||
|
['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)', '(max-width:767px)'],
|
||||||
|
array_column($treffer, 2),
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
|
||||||
|
array_column($treffer, 1),
|
||||||
|
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
|
||||||
|
);
|
||||||
|
foreach ($treffer as $verweis) {
|
||||||
|
// Der Punkt der Übung: Vorabverweis und Regel müssen dieselbe Datei meinen. Sonst
|
||||||
|
// lädt der Browser zwei Bilder statt einem, und das Vorabholen kostet mehr als es
|
||||||
|
// bringt.
|
||||||
|
self::assertStringContainsString('url("'.$verweis[1].'")', $html, 'Der Vorabverweis meint eine andere Datei als die Regel.');
|
||||||
|
self::assertStringContainsString('fetchpriority="high"', $verweis[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dasselbe Bild wird während einer Ausgabe mehrfach gesucht: je Gerät eine Regel, dazu
|
||||||
|
// der Kopf. Gefragt wird trotzdem nur einmal.
|
||||||
|
$mitBild = (int) $antwort->headers->get('X-Kant-Queries');
|
||||||
|
self::assertLessThanOrEqual($sparsam + 1, $mitBild, sprintf(
|
||||||
|
'Ein Hintergrundbild kostet eine Abfrage, nicht vier (%d statt %d).',
|
||||||
|
$mitBild,
|
||||||
|
$sparsam,
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
@unlink($datei);
|
||||||
|
ini_set('memory_limit', (string) $limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
|
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
|
||||||
{
|
{
|
||||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||||
|
|||||||
@@ -235,6 +235,33 @@ final class InstallationTest extends TestCase
|
|||||||
self::fail('Prüfung fehlt: '.$label);
|
self::fail('Prüfung fehlt: '.$label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheSystemCheckNamesTheSqliteVersionBeforeItMatters(): void
|
||||||
|
{
|
||||||
|
// Auf einem echten Hosting gefunden: SQLite war zu alt für die Tabellenfunktionen, mit
|
||||||
|
// denen Doctrine das Schema liest. Die Installation brach erst im dritten Schritt ab, mit
|
||||||
|
// „near "(": syntax error" - eine Meldung, die niemandem sagt, was zu tun ist. Die Fassung
|
||||||
|
// gehört deshalb in die Systemprüfung, vor die Wahl der Datenbank.
|
||||||
|
$version = $this->installation->sqliteVersion();
|
||||||
|
self::assertNotNull($version, 'Auf diesem Rechner gibt es pdo_sqlite - dann muss auch die Fassung lesbar sein.');
|
||||||
|
self::assertMatchesRegularExpression('/^\d+\.\d+/', $version);
|
||||||
|
|
||||||
|
$namen = array_column($this->installation->checks(), 'label');
|
||||||
|
self::assertContains('SQLite-Fassung', $namen);
|
||||||
|
|
||||||
|
$eintrag = null;
|
||||||
|
foreach ($this->installation->checks() as $check) {
|
||||||
|
if ('SQLite-Fassung' === $check['label']) {
|
||||||
|
$eintrag = $check;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($eintrag);
|
||||||
|
self::assertSame($version, $eintrag['found']);
|
||||||
|
// Sie ist eine Empfehlung, kein Hindernis: mit MySQL läuft die Installation auch auf einem
|
||||||
|
// Server mit altem SQLite.
|
||||||
|
self::assertFalse($eintrag['required']);
|
||||||
|
self::assertSame(version_compare($version, Installation::MIN_SQLITE, '>='), $eintrag['ok']);
|
||||||
|
}
|
||||||
|
|
||||||
private function removeDirectory(string $path): void
|
private function removeDirectory(string $path): void
|
||||||
{
|
{
|
||||||
if (!is_dir($path)) {
|
if (!is_dir($path)) {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Ops\Download;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Releaseseite, die im Dateisystem liegt statt im Netz.
|
||||||
|
*
|
||||||
|
* Ein Test, der wirklich nach git.kant-it.de greift, prüft die Leitung und nicht den Updater: er
|
||||||
|
* schlägt fehl, wenn der Server umzieht, und er ist grün, wenn im Archiv Unsinn steht. Diese
|
||||||
|
* Attrappe beantwortet dieselben drei Adressen - Releaseliste, Archiv, Prüfsumme - aus dem
|
||||||
|
* Speicher und merkt sich, was abgefragt wurde.
|
||||||
|
*/
|
||||||
|
final class ReleaseOnDisk extends Download
|
||||||
|
{
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $antworten = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $abgefragt = [];
|
||||||
|
|
||||||
|
public function serve(string $url, string $inhalt): void
|
||||||
|
{
|
||||||
|
$this->antworten[$url] = $inhalt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */
|
||||||
|
public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void
|
||||||
|
{
|
||||||
|
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR));
|
||||||
|
$this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv);
|
||||||
|
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
|
||||||
|
"%s kantsites-%s.zip\n",
|
||||||
|
$checksum ?? hash('sha256', $archiv),
|
||||||
|
$version,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $url): ?string
|
||||||
|
{
|
||||||
|
$this->abgefragt[] = $url;
|
||||||
|
|
||||||
|
return $this->antworten[$url] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user