Compare commits

...
11 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 6f6e5d876c Release 1.29.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6fa303f901 Save consent without reloading the page
consent.js posts the consent form in the background, the server answers
204 with the cookie, and the script swaps in the embeds and the legal line
from a fresh copy of the page. Embed hosts are in frame-src from the start,
the content address still only after consent. Without JavaScript the form
keeps working with a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6390c00876 Rework the Hüfner site after the customer meeting
Tree green from the logo everywhere, the 100 emoji inline wherever 100
appears, downloads for the exemption certificates and the PQ certificate,
the image video behind consent, Google reviews, a 48-hour quote promise,
WhatsApp, two job cards and a page for the alpacas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6ee05f4c70 Remove the duplicated 1.28.1 status entry
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:35:11 +02:00
kantarciandClaude Opus 5.5 1b6cbb2247 Note the 1.28.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:34:58 +02:00
kantarciandClaude Opus 5.5 b298b7469f Release 1.28.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 760efc3297 Stop the rewrite rule from looping on IONOS
IONOS answered every rewritten address with a blank Apache 500: the
!-f condition did not recognise the already rewritten index.php, so the
rule fired again until Apache gave up. RewriteBase and an explicit stop
for index.php, as in WordPress, fix it (ADR 114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 28070298aa Note the 1.28.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:46:20 +02:00
kantarciandClaude Opus 5.5 436c66ecd3 Release 1.28.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:43:12 +02:00
kantarciandClaude Opus 5.5 a6a334fe68 Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503.
The system page checks for a new release on load, shows its notes, runs
backup, download, switch, migration and health check in one flow with live
progress, and switches back automatically when the new version fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:33:53 +02:00
kantarciandClaude Opus 5.5 339a48c810 Note the 1.27.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:37:45 +02:00
27 changed files with 1790 additions and 245 deletions

No files matched your search

+1 -1
View File
@@ -397,7 +397,7 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
### Neunte lokale Referenzwebsite: Hüfner Gartenbau ### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts. Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`. Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
+1 -1
View File
@@ -1 +1 @@
1.27.1 1.29.0
+3
View File
@@ -63,6 +63,9 @@ services:
arguments: arguments:
$secret: '%kernel.secret%' $secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%' $siteOrigin: '%env(SITE_ORIGIN)%'
App\EventSubscriber\ErrorLogSubscriber:
arguments:
$debug: '%kernel.debug%'
App\Controller\SeoController: App\Controller\SeoController:
arguments: arguments:
$siteOrigin: '%env(SITE_ORIGIN)%' $siteOrigin: '%env(SITE_ORIGIN)%'
+33 -1
View File
@@ -1,6 +1,38 @@
# Architekturentscheidungen # Architekturentscheidungen
Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen. Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
**Entscheidung.** Das Formular bleibt der Weg, ein kleines Skript `public/assets/consent.js` verbessert ihn. Es schickt dasselbe Formular im Hintergrund an `/einwilligung` mit der Kopfzeile `X-Kant-Consent: 1`; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an `#main`. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein `data-src` im Platzhalter.
**Was sich dafür ändert.** (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in `frame-src`, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden `script-src 'self'` und laden `consent.js`; nach der Entscheidung ohne wartende Inhalte wieder `script-src 'none'`. (3) Das Skriptbudget von `test:browser` (3 je Seite) zählt `consent.js` nicht mit, weil es nur bis zur Entscheidung da ist.
**Folgen.** Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als `PageController` (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
## ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf `/`, `/install` und `/assets/` mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. `/index.php/install` lieferte dagegen 200, auch mit der `.htaccess` – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: `RewriteCond %{REQUEST_FILENAME} !-f` erkennt dort die bereits umgeschriebene `index.php` nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
**Entscheidung.** `public/.htaccess` setzt `RewriteBase /` und beendet die Umschreibung für `index.php` selbst (`RewriteRule ^index\.php$ - [L]`), wie die `.htaccess` von WordPress. Auf dem Hosting von Umut mit beiden Zeilen bestätigt; welche der beiden allein genügt hätte, ist nicht geprüft. `CmsTest` sichert die Zeilen ab.
**Folgen.** `RewriteBase /` setzt voraus, dass die Domain auf `public/` zeigt – das war schon Bedingung, eine Installation in einem Unterordner einer Domain ist nicht vorgesehen. Bestehende Installationen bekommen die Zeilen mit dem nächsten Archivupdate, weil der Updater die `.htaccess` des Releases übernimmt (nur eingeschaltete HTTPS-/HSTS-Zeilen werden erhalten). `Options -Indexes -MultiViews` verträgt IONOS: auf huefner-gartenbau.de wieder eingeschaltet, die Seite lädt weiter. Ausgeliefert mit 1.28.1.
## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update/<lauf>/alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt.
**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026) ## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
+52
View File
@@ -1,5 +1,55 @@
# Projektstatus # Projektstatus
## Fassung 1.29.0: Einwilligung ohne Neuladen (12.10.2026)
**Anlass:** Umut auf der Hüfner-Seite: Beim Einwilligen ins YouTube-Video lädt die Seite komplett neu.
**Neu im Kern (ADR 115):** `public/assets/consent.js` schickt Hinweis und Knopf am eingebetteten Inhalt im Hintergrund ab (`X-Kant-Consent: 1` → 204 mit Cookie statt Weiterleitung), holt die Seite neu und tauscht nur Einbettungen und „Rechtliches“ aus; Fokus an den Rahmen bzw. an den Seitenanfang. Ohne JavaScript und bei Fehlern bleibt das Formular mit Neuladen. `frame-src` nennt die Hosts aller Einbettungen der Seite auch vor der Einwilligung (die Adresse des Inhalts weiterhin erst danach im HTML). Das Skript lädt nur, solange eine Einwilligung offen ist; `test:browser` zählt es nicht zum Budget von drei Skripten.
**Geprüft:** PHPUnit vollständig 644 grün (neu: 204-Antwort mit Cookie, kein Cookie von fremder Herkunft, Weiterleitung ohne Kopfzeile; angepasst: Skript bei offener Frage, `script-src 'none'` nach der Entscheidung, `frame-src` vor der Einwilligung), `npm test` 41, `lint:twig`, php-cs-fixer. Im Browser auf der Hüfner-Kopie: Knopf am Video, „Nur notwendige“ und „Alle akzeptieren“ ohne Neuladen, Fokus aufs Video, ohne JavaScript weiterhin mit Neuladen, keine Konsolenfehler. `test:browser` grün, Lighthouse Produktion Startseite mobil 96, Desktop 98, sonst 100. `test:editor` nicht gelaufen (Editor unverändert, Skript dort nicht eingebunden).
**Nächster Schritt:** Installationen mit Einwilligungshinweis (huefner-gartenbau.de, Immobilien DEEG) auf 1.29.0 aktualisieren.
## Fassung 1.28.1: weißer 500er bei IONOS durch die Umschreibregel (12.10.2026)
**Anlass:** huefner-gartenbau.de bei IONOS zeigte nach der Einrichtung auf jeder Adresse einen weißen 500er.
**Ursache und Änderung (ADR 114):** PHP 8.4.26 lief (`test.php`), `/index.php/install` lieferte 200, nur umgeschriebene Adressen scheiterten – Umschreibschleife in `public/.htaccess`. Neu im Kern: `RewriteBase /` und `RewriteRule ^index\.php$ - [L]` vor der `!-f`-Regel; Test in `CmsTest::testEveryPublicAnswerCarriesItsProtectiveHeaders`.
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
**Veröffentlicht:** Tag `v1.28.1`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
## Hüfner: Änderungen aus dem Kundentermin (12.10.2026)
**Anlass:** Umuts handschriftliche Notizen vom Hüfner-Termin plus Antworten auf Rückfragen. Nur Inhalt und Seed-Skript, kein Kerncode.
**Erledigt (`scripts/seed-huefner.php`, `build-huefner-assets.php`, `reset-huefner.sh`):** Alle Grüntöne auf das Baumgrün des Logos (#067F30) gedreht, „Premium“ und Hervorhebungen in Baumgrün, kleine Schrift auf Dunkel weiß statt hellgrün (Kontrast). Das „💯“ als Umuts Emoji-Bild überall, wo „100“ steht, im Text (`<mark>100</mark>`: die Zahl bleibt lesbar für Vorleser und KI, sichtbar ist das Bild in Schriftgröße, geschütztes Leerzeichen vor „%“); Quelle `100-emoji.png` in den Unterlagen. „Miteinander. 💯 %.“-Abzeichen jetzt dunkel statt rot. Freistellungsbescheinigungen (§ 13b UStG, § 48b EStG) als PDF bei „Planung & Angebot“ und im Fuß, PQ-Bescheinigung unter „Über uns“ und im Fuß, Galabau-Signet und PQ-Logo. Imagevideo (YouTube, youtube-nocookie) als „Externer Inhalt“ – dafür jetzt Einwilligungshinweis (Ecke) und angepasste Datenschutzerklärung. Google-Bewertungen unter „100 % Qualität. 100 % Zufriedenheit.“ mit 4,9 / über 150 als Beleg, drei echte Rezensionen (gekürzt, Namen abgekürzt), Knopf zum Eintrag. 48-h-Angebotsgarantie in Ablauf und Kontakt, „Leistung“ aus dem Formular, Chips verlinken zum Formular, WhatsApp (0176 30387990) in Kontakt und Karriere, 25 Jahre / 7.000+ Projekte, „Bagger bis 15 t“ raus. Karriere: Facharbeiter und LKW-Fahrer C/CE (wie Instagram) als zwei gleich hohe, aufklappbare Karten (details/summary, SVG-Zeichnungen), „Ein Anruf genügt!“. Neue Seite `/alpakas` mit Umuts Text, Ergänzungen, Galerie und Plakat; „Alpakas“ im Menü, „Ablauf“ aus dem Menü raus. `browser-check.mjs`: PDFs zählen beim „erster interner Link“-Klick nicht mit.
**Geprüft:** `test:browser` auf 8102 für `/`, `/alpakas`, `/datenschutz`, `/impressum` grün (HTML Startseite 148.905 B von 150.000 – knapp). Lighthouse Produktion (`reports/lighthouse-huefner-termin`): `/` mobil 96, Desktop 99; `/alpakas` mobil 95, Desktop 100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing überall 100. Im Browser: Video lädt erst nach Einwilligung, Stellenkarten klappen auf, Telefonbreite ohne waagrechtes Scrollen.
**Bekannt / offen:** Die Freistellungsbescheinigung § 13b gilt nur bis 31.12.2026 – dann neue Datei in die Unterlagen und neu aufbauen. Die Alpaka-Videos (mp4) sind nicht eingebunden (keine Videoablage im Kern). Offen von Umut: Logos der Sponsoren, „Über uns – mehr lesen“, ggf. Stellenbilder aus ChatGPT statt der SVG-Zeichnungen, genauer Verbandsname. Die Sprachtest-Kopie (`var/browser-tests/lang`) teilt `var/huefner-media` und hat nach dem Neuaufbau keine passenden Bilder mehr. Sicherung vorher: `var/backups/huefner-vor-termin-1210/`. Nebenbefund Kern: `CustomCode::parse` lehnt `url('…')` mit Anführungszeichen immer ab (optionales Anführungszeichen im regulären Ausdruck backtrackt).
**Nächster Schritt:** Umuts Durchsicht; fehlende Punkte nachliefern lassen.
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank.
**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung.
**Veröffentlicht:** Tag `v1.28.0`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Bekannt:** Der neue Ablauf greift erst bei Updates, die von 1.28.0 ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig).
**Nächster Schritt:** kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren.
## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026) ## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026)
**Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“ **Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“
@@ -14,6 +64,8 @@
**Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen. **Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen.
**Veröffentlicht:** Tag `v1.27.1`, Release-Eintrag mit Archiv (3607 Dateien, 5,3 MB, gebaut aus `var/release-source-1.27.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Die lokalen Installationen laufen aus diesem Repository und haben 1.27.1 (keine Migration).
**Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren. **Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren.
## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026) ## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026)
+7
View File
@@ -2,7 +2,14 @@ DirectoryIndex index.php
Options -Indexes -MultiViews Options -Indexes -MultiViews
# Alles, was keine Datei ist, beantwortet PHP. # Alles, was keine Datei ist, beantwortet PHP.
#
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
# Absicherung steht in der .htaccess von WordPress.
RewriteEngine On RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L] RewriteRule ^ index.php [L]
+31
View File
@@ -1049,3 +1049,34 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
.language-texts h3 { margin: 0 0 6px; font-size: 15px } .language-texts h3 { margin: 0 0 6px; font-size: 15px }
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 } .language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
.language-text-group legend { font-weight: 600; padding: 0 4px } .language-text-group legend { font-weight: 600; padding: 0 4px }
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
@keyframes update-dreh { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
.update-neu h3 { margin: 0; font-size: 17px }
.update-neu .muted { margin: 0 }
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
.update-schritt.is-offen { color: #8f9e96 }
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
.update-meldung.is-ok { color: #9fdcb5 }
.update-meldung.is-fehler { color: #ffb4a6 }
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
+91
View File
@@ -0,0 +1,91 @@
// Einwilligung ohne Neuladen (ADR 115).
//
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
// erst nach der Einwilligung im Dokument.
//
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
// laden als eine Entscheidung, die nicht ankommt.
(() => {
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
if (forms.length === 0) {
return;
}
const replaceFrom = async () => {
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
if (!response.ok) {
throw new Error('Seite nicht geladen');
}
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
const next = fresh.getElementById(blocked.id);
if (next && !next.classList.contains('embed-blocked')) {
blocked.replaceWith(document.importNode(next, true));
}
}
for (const legal of document.querySelectorAll('.block-legal[id]')) {
const next = fresh.getElementById(legal.id);
if (next) {
legal.replaceWith(document.importNode(next, true));
}
}
};
for (const form of forms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = event.submitter;
const body = new FormData(form, button);
const placeholder = form.closest('.embed-blocked');
for (const control of form.querySelectorAll('button')) {
control.disabled = true;
}
try {
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
if (!saved.ok) {
throw new Error('Entscheidung nicht gespeichert');
}
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
await replaceFrom();
}
document.querySelector('.consent-bar')?.remove();
if (placeholder) {
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
// seiner Stelle steht.
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
frame?.focus();
} else {
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
}
} catch {
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
for (const control of form.querySelectorAll('button')) {
control.disabled = false;
}
withChoice(form, button).submit();
}
});
}
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
function withChoice(form, button) {
if (button?.name) {
const field = document.createElement('input');
field.type = 'hidden';
field.name = button.name;
field.value = button.value;
form.append(field);
}
return form;
}
})();
+190
View File
@@ -0,0 +1,190 @@
/*
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
*
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
* und die Seite sagt es.
*
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
*/
const karte = document.querySelector('[data-update]');
if (karte) {
const live = karte.querySelector('[data-update-live]');
const dialog = document.getElementById('update-dialog');
const csrf = karte.dataset.csrf;
const SCHRITTE = [
['sichern', 'Daten sichern'],
['laden', 'Fassung laden und prüfen'],
['umschalten', 'Umschalten'],
['migrieren', 'Datenbank anpassen'],
['pruefen', 'Funktion prüfen'],
];
let ziel = null, timer = null, laeuft = false;
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
const abfragen = async url => {
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
if (!antwort.ok) throw new Error(String(antwort.status));
return antwort.json();
};
const senden = async (url, daten) => {
const antwort = await fetch(url, {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
body: new URLSearchParams(daten),
});
const text = await antwort.text();
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
};
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
function notizen(text) {
const box = el('div', 'update-notizen');
text.split('\n').forEach((zeile, index) => {
if (index) box.append(document.createElement('br'));
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
});
});
return box;
}
// ------------------------------------------------------------------ Ansicht: Prüfen
function warten(text) {
live.hidden = false;
live.replaceChildren(el('p', 'update-wait', text));
live.firstChild.prepend(el('span', 'update-spinner'));
}
function zeigeErgebnis(daten) {
live.hidden = false;
live.replaceChildren();
const letzter = daten.status;
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
live.append(box);
}
if (daten.fehler) {
const zeile = el('p', 'notice error', daten.fehler + ' ');
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
nochmal.addEventListener('click', pruefen);
zeile.append(nochmal); live.append(zeile);
return;
}
if (!daten.neu) {
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
return;
}
ziel = daten.neu.version;
const box = el('div', 'update-neu');
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
box.append(knopf);
live.append(box);
}
async function pruefen() {
warten('Frage nach neuen Fassungen …');
try {
const daten = await abfragen(karte.dataset.pruefen);
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
zeigeErgebnis(daten);
} catch {
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
}
}
// ------------------------------------------------------------------ Ansicht: Lauf
function zeigeLauf(status, server = true) {
live.hidden = false;
const box = el('div', 'update-lauf');
const kopf = el('p', 'update-lauf-kopf');
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
if (!ende) kopf.append(el('span', 'update-spinner'));
kopf.append(ende
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
: `Update auf ${status.auf || ziel || ''} läuft`);
box.append(kopf);
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
const aktuell = gescheitert
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
const liste = el('ol', 'update-schritte');
SCHRITTE.forEach(([phase, text], index) => {
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
if (gescheitert && index === aktuell) zustand = 'fehler';
const punkt = el('li', `update-schritt is-${zustand}`);
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
liste.append(punkt);
});
box.append(liste);
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
box.append(meldung);
if (ende) {
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
weiter.addEventListener('click', () => location.reload());
box.append(weiter);
}
live.replaceChildren(box);
}
async function tick() {
try {
const daten = await abfragen(karte.dataset.status);
if (daten.status) zeigeLauf(daten.status);
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
} catch {
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
const letzter = live.querySelector('.update-meldung');
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
}
}
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
function stoppen() { clearInterval(timer); timer = null; }
async function starten() {
dialog.close();
laeuft = true;
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
verfolgen();
try {
const start = await senden(karte.dataset.starten, { version: ziel });
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
laeuft = false;
await tick();
stoppen();
if (ende.ok) setTimeout(() => location.reload(), 2500);
} catch (fehler) {
laeuft = false;
await tick();
stoppen();
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
}
}
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
pruefen();
}
+11
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Kernel; use App\Kernel;
use App\Ops\Notfall;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt // PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem. // Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
header_remove('X-Powered-By'); header_remove('X-Powered-By');
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
Notfall::einschalten(dirname(__DIR__));
try {
require dirname(__DIR__).'/config/bootstrap.php'; require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals(); $request = Request::createFromGlobals();
$response = $kernel->handle($request); $response = $kernel->handle($request);
$response->send(); $response->send();
$kernel->terminate($request, $response); $kernel->terminate($request, $response);
} catch (Throwable $fehler) {
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
}
+10 -5
View File
@@ -69,7 +69,8 @@ try {
const queries = Number(response.headers()['x-kant-queries'] ?? -1); const queries = Number(response.headers()['x-kant-queries'] ?? -1);
const html = (await response.text()).length; const html = (await response.text()).length;
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0); const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length; // consent.js zählt nicht: nur solange eine Einwilligung offen ist (ADR 115).
const scripts = [...transferred.entries()].filter(([url, entry]) => entry.type.includes('javascript') && !url.includes('/assets/consent.js')).length;
const requests = transferred.size; const requests = transferred.size;
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine // Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das // Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
@@ -114,7 +115,9 @@ try {
assert.equal(await page.locator('h1').count(), 1); assert.equal(await page.locator('h1').count(), 1);
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt // Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt. // wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count(); // consent.js zählt nicht mit: Es steht nur auf der Seite, solange eine Einwilligung offen ist,
// und nach der Entscheidung nicht mehr (ADR 115).
const laufend = await page.locator('script:not([type="application/ld+json"]):not([src^="/assets/consent.js"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? ''); const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`); assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein // Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
@@ -162,7 +165,8 @@ try {
const hinweis = page.locator('.consent-bar button[value="none"]'); const hinweis = page.locator('.consent-bar button[value="none"]');
if (await hinweis.count()) { if (await hinweis.count()) {
await hinweis.click(); await hinweis.click();
await page.waitForLoadState('domcontentloaded'); // Mit consent.js ohne Neuladen (ADR 115): warten, bis der Hinweis weg ist.
await page.locator('.consent-bar').waitFor({ state: 'detached' });
} }
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen'); assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
@@ -171,7 +175,8 @@ try {
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich // Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere // woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen. // Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
const ersterLink = page.locator('main a[href^="/"]').first(); // Ein PDF zum Herunterladen ist keine Seite mit Überschrift; es zählt hier nicht mit.
const ersterLink = page.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const ziel = await ersterLink.getAttribute('href'); const ziel = await ersterLink.getAttribute('href');
await ersterLink.click(); await ersterLink.click();
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/'); assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
@@ -189,7 +194,7 @@ try {
await noJs.waitForLoadState('domcontentloaded'); await noJs.waitForLoadState('domcontentloaded');
} }
assert.equal(await noJs.locator('h1').count(), 1); assert.equal(await noJs.locator('h1').count(), 1);
const ohneJs = noJs.locator('main a[href^="/"]').first(); const ohneJs = noJs.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const zielOhneJs = await ohneJs.getAttribute('href'); const zielOhneJs = await ohneJs.getAttribute('href');
await ohneJs.click(); await ohneJs.click();
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/'); assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
+37 -1
View File
@@ -7,7 +7,12 @@
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im * den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar. * Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
* *
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner> * Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner> [<Unterlagen>]
*
* Das dritte Argument ist Umuts Ordner „unterlagenfrwebsite“ (12.10.2026): Alpaka-Fotos, PQ-Logo,
* Galabau-Signet und die drei PDFs. Sie bekommen hier feste, einfache Namen, damit das Seed-Skript
* nicht an „Freistellungsbescheinigung §48 … bis 11.05.2029.pdf“ hängt. Kommt eine neue
* Bescheinigung, genügt es, sie nach dem gleichen Muster benannt in den Ordner zu legen.
*/ */
$quelle = rtrim($argv[1] ?? '', '/\\'); $quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\'); $ziel = rtrim($argv[2] ?? '', '/\\');
@@ -43,3 +48,34 @@ $zh = (int) round($h * $massstab);
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h); imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
imagepng($symbol, $ziel.'/favicon.png'); imagepng($symbol, $ziel.'/favicon.png');
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n"; echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
$unterlagen = rtrim($argv[3] ?? '', '/\\');
if ('' === $unterlagen) {
exit(0);
}
// Muster im Ordner => fester Name. Bei mehreren Treffern gewinnt der letzte in Namensreihenfolge.
$feste = [
'Alpaka (1).jpeg' => 'alpaka-plakat.jpg',
'Alpaka (2).jpeg' => 'alpaka-wiese.jpg',
'Alpaka (3).jpeg' => 'alpaka-stall.jpg',
'Alpaka (4).jpeg' => 'alpaka-blume.jpg',
'Alpaka (6).jpeg' => 'alpaka-zaunbau.jpg',
'Alpaka (9).jpeg' => 'alpaka-rasen.jpg',
'PQ-Logo*.png' => 'pq-logo.png',
'Signum*.png' => 'galabau-signet.png',
'100-emoji.png' => 'hundert.png',
'PQ-Bescheinigung*.pdf' => 'pq-bescheinigung.pdf',
'Freistellungsbescheinigung §13*.pdf' => 'freistellung-13b.pdf',
'Freistellungsbescheinigung §48*.pdf' => 'freistellung-48b.pdf',
];
$dateien = scandir($unterlagen) ?: [];
foreach ($feste as $muster => $name) {
$treffer = array_values(array_filter($dateien, static fn (string $d): bool => fnmatch($muster, $d, FNM_NOESCAPE)));
if ([] === $treffer) {
fwrite(STDERR, "Fehlt in den Unterlagen: $muster\n");
exit(1);
}
sort($treffer);
copy($unterlagen.'/'.end($treffer), $ziel.'/'.$name);
}
echo 'Unterlagen übernommen: '.count($feste)." Dateien\n";
+4 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht. # Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/). # Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
# Zweites Argument: Umuts Unterlagen (Alpakas, PQ, Freistellungsbescheinigungen, Signet).
set -e set -e
root="C:/Django Project/KantSites" root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}" php="${PHP_BINARY:-C:/php84/php.exe}"
@@ -8,10 +9,10 @@ export DATABASE_URL="sqlite:///$root/var/huefner.db"
export MEDIA_DIRECTORY="$root/var/huefner-media" export MEDIA_DIRECTORY="$root/var/huefner-media"
assets="$root/var/huefner-assets" assets="$root/var/huefner-assets"
if [ -n "$1" ]; then if [ -n "$1" ]; then
"$php" scripts/build-huefner-assets.php "$1" "$assets" "$php" scripts/build-huefner-assets.php "$1" "$assets" "$2"
fi fi
if [ ! -f "$assets/favicon.png" ]; then if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/pq-bescheinigung.pdf" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2 echo "Bilder fehlen: erst mit dem Ordner des Entwurfs und den Unterlagen als Argumente aufrufen." >&2
exit 1 exit 1
fi fi
rm -f "$root/var/huefner.db" rm -f "$root/var/huefner.db"
+550 -183
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -33,7 +33,12 @@ final class ConsentController extends AbstractController
'none' => [], 'none' => [],
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')), default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
}; };
$response = $this->redirect($this->backTo($request)); // consent.js schickt dasselbe Formular im Hintergrund und kennzeichnet das mit dieser
// Kopfzeile: dann keine Weiterleitung, nur das Cookie - die Seite bleibt stehen und das
// Skript tauscht aus, was jetzt geladen werden darf (ADR 115). Ein fremdes Formular kann
// die Kopfzeile nicht setzen; ein fremdes Skript bräuchte dafür eine CORS-Freigabe.
$response = '1' === $request->headers->get('X-Kant-Consent') ? new Response(status: Response::HTTP_NO_CONTENT) : $this->redirect($this->backTo($request));
$response->headers->set('Cache-Control', 'no-store');
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular, // Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und // das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage. // damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
+23 -13
View File
@@ -371,7 +371,13 @@ final class PageController extends AbstractController
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen. // Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
$request->attributes->set('kant_alternates', $alternates); $request->attributes->set('kant_alternates', $alternates);
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : ''); $canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
// Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt
// consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das
// Formular mit Weiterleitung.
$embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections));
$needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request);
$response = $this->render('page.html.twig', [ $response = $this->render('page.html.twig', [
'needsConsent' => $needsConsent,
'page' => $page, 'page' => $page,
'canonical' => $canonical, 'canonical' => $canonical,
'alternates' => $alternates, 'alternates' => $alternates,
@@ -429,8 +435,8 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form')); $mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document'])); $wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); $frames = $embeds['hosts'];
if ($mitTurnstile) { if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com'; $frames[] = 'challenges.cloudflare.com';
} }
@@ -453,9 +459,9 @@ final class PageController extends AbstractController
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than * The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked. * by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
* *
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response. * @param list<string> $frameHosts Hosts of the embeds on this page, also of those still
* Nothing is added to the policy for an embed still waiting * waiting for consent (ADR 115: consent.js inserts them
* for consent. * without a reload, and a policy cannot change afterwards)
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer) * @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
*/ */
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
@@ -1079,23 +1085,27 @@ final class PageController extends AbstractController
} }
/** /**
* The hosts of embeds that may load on this request. * The hosts of all embeds on this page, and whether one of them still waits for consent.
* *
* An embed whose group is not granted contributes nothing: its host must not appear in the * Seit ADR 115 steht der Host auch dann in frame-src, wenn die Gruppe noch nicht erlaubt ist:
* policy, because the frame is not rendered either. * Die Richtlinie einer Seite steht beim Laden fest, und consent.js setzt den Rahmen nach der
* Einwilligung ohne Neuladen ein. Die Erlaubnis lädt nichts; die Adresse des Inhalts steht
* weiterhin erst nach der Einwilligung im HTML.
* *
* @return list<string> * @return array{hosts: list<string>, blocked: bool}
*/ */
private function frameHosts(Request $request, array ...$documents): array private function embedState(Request $request, array ...$documents): array
{ {
$hosts = []; $hosts = [];
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void { $blocked = false;
$walk = function (array $blocks) use (&$walk, &$hosts, &$blocked, $request): void {
foreach ($blocks as $block) { foreach ($blocks as $block) {
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) { if ('embed' === ($block['type'] ?? null) && '' !== ($block['props']['url'] ?? '')) {
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST); $host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
if (is_string($host)) { if (is_string($host)) {
$hosts[$host] = true; $hosts[$host] = true;
} }
$blocked = $blocked || !$this->consent->granted($request, $block['props']['group'] ?? '');
} }
$walk($block['children'] ?? []); $walk($block['children'] ?? []);
} }
@@ -1104,7 +1114,7 @@ final class PageController extends AbstractController
$walk($document['blocks'] ?? []); $walk($document['blocks'] ?? []);
} }
return array_keys($hosts); return ['hosts' => array_keys($hosts), 'blocked' => $blocked];
} }
/** /**
+112 -1
View File
@@ -3,6 +3,8 @@
namespace App\Controller; namespace App\Controller;
use App\Command\SiteOriginCommand; use App\Command\SiteOriginCommand;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidPage; use App\Domain\InvalidPage;
use App\Ops\ArchiveUpdate; use App\Ops\ArchiveUpdate;
use App\Ops\Backup; use App\Ops\Backup;
@@ -11,8 +13,10 @@ use App\Ops\Installation;
use App\Ops\OriginCheck; use App\Ops\OriginCheck;
use App\Ops\Updater; use App\Ops\Updater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -24,7 +28,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 ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck) public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings)
{ {
} }
@@ -189,6 +193,113 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system'); return $this->redirectToRoute('admin_system');
} }
// -----------------------------------------------------------------------------------------
// Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt
// beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite
// zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“.
// -----------------------------------------------------------------------------------------
/** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */
#[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])]
public function updateCheck(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$installiert = $this->updater->version();
$neueste = $this->archive->latest();
$antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()];
if (null === $neueste) {
$antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.';
} elseif (version_compare($neueste['version'], $installiert, '>')) {
$antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)];
}
return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']);
}
/** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */
#[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])]
public function updateStatus(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']);
}
/**
* Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch
* als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite
* danach selbst auf.
*/
#[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])]
public function updateStart(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) {
return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403);
}
@set_time_limit(0);
$ergebnis = $this->archive->run($request->request->getString('version') ?: null);
if ($ergebnis['migrate'] ?? false) {
$this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]);
}
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]);
}
/**
* Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der
* Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist
* wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte.
*/
#[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])]
public function updateFinish(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$offen = $this->archive->pending();
if (null === $offen) {
return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]);
}
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) {
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation, $this->pruefen(...));
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
}
/**
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite
* als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut.
*/
private function pruefen(): ?string
{
$home = $this->settings->homePath();
if (null === $this->pages->findPublished($home)) {
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
}
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
if ($antwort->getStatusCode() >= 400) {
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
}
return null;
}
/** Wie sendAndStop, nur als JSON für die Systemseite. */
private function sendJsonAndStop(array $daten): never
{
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code(200);
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** /**
* Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112). * Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112).
* *
@@ -0,0 +1,50 @@
<?php
namespace App\EventSubscriber;
use App\Ops\Notfall;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\LogoutException;
/**
* Jeder Serverfehler, den das Framework abfängt, steht auch in var/log/notfall.log (ADR 113).
*
* Ohne das gab es auf dem Hosting kein eigenes Protokoll: Symfony schreibt nach stderr, und das
* landet - wenn überhaupt - im Fehlerlog des Hostings, das ein Betreiber erst suchen muss. 404 und
* andere gewollte Antworten gehören nicht hinein. Die Antwort selbst wird zur Wartungsseite.
*/
final class ErrorLogSubscriber implements EventSubscriberInterface
{
public function __construct(private bool $debug = false)
{
}
public static function getSubscribedEvents(): array
{
// Nach der Sicherheitsschicht (Priorität 1): „Zugriff verweigert“ ist dort eine
// Weiterleitung zur Anmeldung, kein Notfall. Vor der Fehlerseite des Frameworks (-128),
// damit der Eintrag auch dann entsteht, wenn sie selbst scheitert.
return [KernelEvents::EXCEPTION => ['onException', -64]];
}
public function onException(ExceptionEvent $event): void
{
$fehler = $event->getThrowable();
if ($event->hasResponse() || $fehler instanceof AuthenticationException || $fehler instanceof AccessDeniedException || $fehler instanceof LogoutException
|| ($fehler instanceof HttpExceptionInterface && $fehler->getStatusCode() < 500)) {
return;
}
Notfall::protokollieren($fehler, $event->getRequest()->getMethod().' '.$event->getRequest()->getRequestUri());
// Besucher sehen statt einer englischen „Oops“-Seite die Wartungsseite mit 503: für
// Suchmaschinen „gleich wieder da“. Im Entwicklungsmodus bleibt die ausführliche Seite.
if (!$this->debug) {
$event->setResponse(new Response(Notfall::html(), Response::HTTP_SERVICE_UNAVAILABLE, Notfall::KOPFZEILEN));
}
}
}
+183 -5
View File
@@ -51,6 +51,21 @@ final class ArchiveUpdate
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */ /** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
private const PROTOKOLL = 'var/updates.log'; private const PROTOKOLL = 'var/updates.log';
/**
* Der Fortschritt für die Systemseite (ADR 113): Phase, Meldung, von welcher auf welche Fassung.
* Die Seite fragt ihn alle zwei Sekunden ab, während eine andere Anfrage das Update ausführt -
* das Vorbild ist reiseSoft.
*/
public const STATUS = 'var/update/status.json';
/** Die Phasen, die die Systemseite als Schritte zeigt, in ihrer Reihenfolge. */
public const PHASEN = ['sichern', 'laden', 'umschalten', 'migrieren', 'pruefen'];
/** Was mergePublic() überschrieben oder neu angelegt hat - für das Zurückschalten. */
private array $publicAlt = [];
private array $publicNeu = [];
private ?string $publicSicherung = null;
public function __construct( public function __construct(
private string $root, private string $root,
private Updater $updater, private Updater $updater,
@@ -85,6 +100,10 @@ final class ArchiveUpdate
$version = $treffer[1]; $version = $treffer[1];
return [ return [
// Was die Releaseseite über die Fassung sagt - die Systemseite zeigt es vor dem Knopf.
'name' => (string) ($daten['name'] ?? ''),
'notes' => (string) ($daten['body'] ?? ''),
'published' => (string) ($daten['published_at'] ?? ''),
'version' => $version, 'version' => $version,
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $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)), 'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
@@ -118,6 +137,12 @@ final class ArchiveUpdate
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool { $melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail]; $schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail)); $this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
// Ein gescheiterter Schritt vor dem Tausch: die bisherige Fassung läuft unverändert.
// Die Sperre meldet ein anderes, laufendes Update - dessen Fortschritt bleibt stehen.
// Ist die Releaseseite nicht erreichbar, hat nichts begonnen; dann bleibt alles, wie es ist.
if (!$ok && !in_array($schritt, ['Sperre', 'Fassung ermitteln'], true)) {
$this->phase('fehler', $detail.' Die bisherige Fassung läuft unverändert weiter.', ['laeuft' => false]);
}
return $ok; return $ok;
}; };
@@ -136,6 +161,7 @@ final class ArchiveUpdate
return ['ok' => true, 'steps' => $schritte]; return ['ok' => true, 'steps' => $schritte];
} }
$this->phase('sichern', 'Datenbank und Medien werden gesichert …', ['laeuft' => true, 'von' => $this->updater->version(), 'auf' => $ziel, 'seit' => gmdate('c')], true);
$lock = $this->updater->lock(); $lock = $this->updater->lock();
if (null === $lock) { if (null === $lock) {
@@ -165,6 +191,7 @@ final class ArchiveUpdate
try { try {
$sicherung = $this->backup->create('vor dem Update'); $sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung'))); $melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
} catch (\Throwable $fehler) { } catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.'); $melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
@@ -211,6 +238,7 @@ final class ArchiveUpdate
} }
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.'); $melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
$this->phase('umschalten', 'Wartungsseite an, die neuen Dateien werden eingesetzt …');
$this->updater->maintenance(true); $this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.'); $melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
@@ -229,11 +257,14 @@ final class ArchiveUpdate
++$ersetzt; ++$ersetzt;
} }
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem // public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg. // Betreiber und wären sonst weg. Was dabei überschrieben wird, kommt vorher beiseite,
// damit ein Zurückschalten auch public/ zurückbringt.
$this->publicSicherung = $arbeit.'/alt-public';
$this->mergePublic($quelle.'/public', $this->root.'/public'); $this->mergePublic($quelle.'/public', $this->root.'/public');
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber // Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt. // installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins'); $this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
@file_put_contents($arbeit.'/public.json', json_encode(['alt' => $this->publicAlt, 'neu' => $this->publicNeu], JSON_UNESCAPED_SLASHES));
} catch (\Throwable $fehler) { } catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) { foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) { if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@@ -265,7 +296,11 @@ final class ArchiveUpdate
'seit' => gmdate('c'), 'seit' => gmdate('c'),
'alt' => basename($arbeit), 'alt' => basename($arbeit),
'token' => $token, 'token' => $token,
// Für das Zurückschalten: von welcher Fassung, und welche Sicherung dazu gehört.
'von' => (string) ($this->status()['von'] ?? ''),
'sicherung' => (string) ($sicherung['name'] ?? ''),
], JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_SLASHES));
$this->phase('migrieren', 'Die neuen Dateien sind da. Die Datenbank wird angepasst …');
$melden($schritte, 'Dateien sind ersetzt', true, sprintf( $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.', 'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
$ziel, $ziel,
@@ -302,25 +337,49 @@ final class ArchiveUpdate
* *
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>} * @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/ */
public function finish(Installation $installation): array /**
* @param (callable(): ?string)|null $pruefen prüft die neue Fassung nach den Migrationen und
* gibt null zurück oder den Grund, warum sie nicht
* läuft - dann wird zurückgeschaltet
*/
public function finish(Installation $installation, ?callable $pruefen = null): array
{ {
$schritte = []; $schritte = [];
$offen = $this->pending();
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s'))); $this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
$this->phase('migrieren', 'Die Datenbank wird angepasst …', ['laeuft' => true]);
try { try {
$namen = $installation->migrate(); $namen = $installation->migrate();
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen $schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.' ? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))]; : sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
} catch (\Throwable $fehler) { } catch (\Throwable $fehler) {
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.']; $schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage()];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage()); $this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
return ['ok' => false, 'steps' => $schritte]; return $this->zurueckschalten($offen, 'Die Datenbank ließ sich nicht anpassen: '.$fehler->getMessage(), $schritte);
}
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
if (null !== $pruefen) {
$this->phase('pruefen', 'Die neue Fassung wird geprüft …');
try {
$problem = $pruefen();
} catch (\Throwable $fehler) {
$problem = $fehler->getMessage();
}
if (null !== $problem) {
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => false, 'detail' => $problem];
$this->protokoll('FEHLER Funktion prüfen: '.$problem);
return $this->zurueckschalten($offen, 'Die neue Fassung lief nicht: '.$problem, $schritte);
}
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => true, 'detail' => 'Startseite und Datenbank antworten.'];
$this->protokoll('ok Funktion prüfen: Startseite und Datenbank antworten.');
} }
@unlink($this->root.'/'.self::MARKE); @unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false); $this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) { foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
@unlink($this->root.'/public/'.$eintrag); @unlink($this->root.'/public/'.$eintrag);
} }
@@ -329,10 +388,109 @@ final class ArchiveUpdate
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).', 'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
$this->updater->version(), $this->updater->version(),
)]; )];
$this->phase('fertig', sprintf('Fassung %s läuft.', $this->updater->version()), ['laeuft' => false]);
return ['ok' => true, 'steps' => $schritte]; return ['ok' => true, 'steps' => $schritte];
} }
/**
* Zurück auf die Fassung von vorher: Code aus var/update/<lauf>/alt, die überschriebenen
* Dateien in public/ und plugins/, dann die Datenbank aus der Sicherung vor dem Update.
* Erst danach geht die Wartungsseite aus - eine halb zurückgeschaltete Website darf niemand
* sehen.
*/
private function zurueckschalten(?array $offen, string $grund, array $schritte): array
{
$bei = $this->status()['phase'] ?? null;
$this->phase('zurueckschalten', 'Etwas ist schiefgegangen – die bisherige Fassung wird wiederhergestellt …', ['bei' => in_array($bei, self::PHASEN, true) ? $bei : null]);
$arbeit = $this->root.'/var/update/'.basename((string) ($offen['alt'] ?? ''));
$fehler = [];
if (null === $offen || !is_dir($arbeit.'/alt')) {
$fehler[] = 'Der beiseitegelegte Stand fehlt.';
} else {
@mkdir($arbeit.'/verworfen', 0o775, true);
foreach (self::ERSETZEN as $eintrag) {
if (!file_exists($arbeit.'/alt/'.$eintrag)) {
continue;
}
if (file_exists($this->root.'/'.$eintrag) && !@rename($this->root.'/'.$eintrag, $arbeit.'/verworfen/'.$eintrag)) {
$fehler[] = $eintrag.' ließ sich nicht beiseitelegen.';
continue;
}
if (!@rename($arbeit.'/alt/'.$eintrag, $this->root.'/'.$eintrag)) {
$fehler[] = $eintrag.' ließ sich nicht zurücklegen.';
}
}
$liste = json_decode((string) @file_get_contents($arbeit.'/public.json'), true);
foreach ((array) ($liste['neu'] ?? []) as $relativ) {
@unlink($this->root.'/'.$relativ);
}
foreach ((array) ($liste['alt'] ?? []) as $relativ) {
if (!@copy($arbeit.'/alt-public/'.$relativ, $this->root.'/'.$relativ)) {
$fehler[] = $relativ.' ließ sich nicht zurückkopieren.';
}
}
}
if ('' !== (string) ($offen['sicherung'] ?? '')) {
$wieder = $this->backup->restore((string) $offen['sicherung']);
if (!$wieder['ok']) {
$fehler[] = 'Die Datenbank ließ sich nicht aus der Sicherung zurückholen.';
}
}
$von = (string) ($offen['von'] ?? '');
if ([] === $fehler) {
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$meldung = sprintf('%s Zurückgeschaltet auf %s – die Website läuft wie vorher.', $grund, '' === $von ? 'die vorherige Fassung' : $von);
$schritte[] = ['step' => 'Zurückgeschaltet', 'ok' => true, 'detail' => $meldung];
$this->protokoll('ok Zurückgeschaltet: '.$meldung);
$this->phase('zurueckgerollt', $meldung, ['laeuft' => false]);
} else {
// Bleibt die Wartungsseite an, sieht niemand eine halbe Website; der Rest ist Handarbeit.
$meldung = $grund.' Das Zurückschalten gelang nicht vollständig: '.implode(' ', $fehler).' Die Wartungsseite bleibt an.';
$schritte[] = ['step' => 'Zurückschalten', 'ok' => false, 'detail' => $meldung];
$this->protokoll('FEHLER Zurückschalten: '.$meldung);
$this->phase('fehler', $meldung, ['laeuft' => false]);
}
return ['ok' => false, 'steps' => $schritte, 'zurueckgeschaltet' => [] === $fehler];
}
/** Der letzte Fortschritt, oder null, wenn noch nie ein Update über diesen Weg lief. */
public function status(): ?array
{
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::STATUS), true);
if (!is_array($daten)) {
return null;
}
// Ein Lauf, der seit einer halben Stunde nichts mehr meldet, ist abgebrochen - etwa weil
// das Hosting den Aufruf beendet hat. Dann soll die Seite nicht ewig „läuft“ zeigen.
if (($daten['laeuft'] ?? false) && strtotime((string) ($daten['aktualisiert'] ?? '')) < time() - 1800) {
$daten['laeuft'] = false;
$daten['phase'] = 'fehler';
$daten['meldung'] = 'Das Update hat seit einer halben Stunde nichts mehr gemeldet und gilt als abgebrochen. Den Stand zeigt das Protokoll unten.';
}
return $daten;
}
/** Schreibt den Fortschritt - in einem Zug, damit die Seite nie eine halbe Datei liest. */
private function phase(string $phase, string $meldung, array $mehr = [], bool $neu = false): void
{
$daten = $neu ? [] : ($this->status() ?? []);
// Bei einem Fehler zählt, in welchem Schritt er passierte - die Seite markiert genau den.
if (in_array($phase, ['fehler', 'zurueckgerollt'], true) && !array_key_exists('bei', $mehr)) {
$mehr['bei'] = in_array($daten['phase'] ?? null, self::PHASEN, true) ? $daten['phase'] : ($daten['bei'] ?? null);
}
$daten = [...$daten, ...$mehr, 'phase' => $phase, 'meldung' => $meldung, 'aktualisiert' => gmdate('c')];
$datei = $this->root.'/'.self::STATUS;
@mkdir(dirname($datei), 0o775, true);
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
if (false !== @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) {
@rename($zwischen, $datei);
}
}
/** /**
* Eine Zeile ins Protokoll, und zwar sofort. * Eine Zeile ins Protokoll, und zwar sofort.
* *
@@ -366,6 +524,7 @@ final class ArchiveUpdate
continue; continue;
} }
$this->merken($ziel);
if ('.htaccess' === $eintrag && is_file($ziel)) { if ('.htaccess' === $eintrag && is_file($ziel)) {
$this->mergeHtaccess($quelle, $ziel); $this->mergeHtaccess($quelle, $ziel);
@@ -375,6 +534,25 @@ final class ArchiveUpdate
} }
} }
/** Merkt sich eine Datei, bevor sie überschrieben oder neu angelegt wird. */
private function merken(string $ziel): void
{
if (null === $this->publicSicherung) {
return;
}
$relativ = ltrim(substr($ziel, strlen($this->root)), '/');
if (!is_file($ziel)) {
$this->publicNeu[] = $relativ;
return;
}
$kopie = $this->publicSicherung.'/'.$relativ;
@mkdir(dirname($kopie), 0o775, true);
if (@copy($ziel, $kopie)) {
$this->publicAlt[] = $relativ;
}
}
/** /**
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat, * Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten * eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Ops;
/**
* Die letzte Sicherung, wenn die Anwendung nicht einmal mehr eine Fehlerseite zustande bringt
* (ADR 113).
*
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach antwortete jede Adresse mit
* einem leeren 500er - für Besucher eine weiße Seite, für den Betreiber kein Hinweis, warum. Der
* Grund stand nur im Fehlerprotokoll des Hostings.
*
* Diese Klasse hängt an nichts aus dem Framework: kein Container, keine Datenbank, keine
* Vorlage. Sie schreibt den Grund nach var/log/notfall.log (per FTP lesbar, außerhalb von
* public/) und zeigt Besuchern eine schlichte Wartungsseite mit 503 - für Suchmaschinen heißt das
* „gleich wieder da“, nicht „kaputt“. Details stehen nie auf der Seite.
*/
final class Notfall
{
public const PROTOKOLL = 'var/log/notfall.log';
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach notfall.log.1. */
private const GRENZE = 1048576;
/** Fehlerarten, nach denen PHP abbricht - nur die gehören auf die Notfallseite. */
private const ABBRUCH = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
public const KOPFZEILEN = [
'Content-Type' => 'text/html; charset=UTF-8',
'Retry-After' => '300',
'Cache-Control' => 'no-store',
'X-Robots-Tag' => 'noindex',
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
];
private static ?string $wurzel = null;
private static bool $gezeigt = false;
/** Einmal ganz am Anfang von public/index.php. */
public static function einschalten(string $wurzel): void
{
self::$wurzel = $wurzel;
register_shutdown_function(static function (): void {
$fehler = error_get_last();
if (null === $fehler || !in_array($fehler['type'], self::ABBRUCH, true)) {
return;
}
self::melden(sprintf('PHP-Abbruch: %s in %s:%d', $fehler['message'], $fehler['file'], $fehler['line']));
self::seite();
// Danach soll nichts mehr ausgeben - auch nicht die Fehlerseite des Frameworks, das
// sich ebenfalls fürs Herunterfahren angemeldet hat. exit in einer Shutdown-Funktion
// beendet die übrigen.
exit;
});
}
/** Eine Ausnahme, die aus dem Framework herausgefallen ist. */
public static function ausnahme(\Throwable $fehler, string $adresse = ''): void
{
self::melden(self::beschreiben($fehler, $adresse));
self::seite();
}
/** Ein Fehler, den das Framework abgefangen und als 500 beantwortet hat - nur protokollieren. */
public static function protokollieren(\Throwable $fehler, string $adresse = ''): void
{
self::melden(self::beschreiben($fehler, $adresse));
}
public static function beschreiben(\Throwable $fehler, string $adresse = ''): string
{
$zeile = sprintf('%s: %s in %s:%d', $fehler::class, $fehler->getMessage(), $fehler->getFile(), $fehler->getLine());
// Die eigentliche Ursache steckt oft eine Ebene tiefer, etwa das SQLite-Problem unter
// einer Doctrine-Ausnahme.
for ($tiefer = $fehler->getPrevious(), $n = 0; null !== $tiefer && $n < 3; $tiefer = $tiefer->getPrevious(), ++$n) {
$zeile .= sprintf(' | Ursache: %s: %s', $tiefer::class, $tiefer->getMessage());
}
return ('' === $adresse ? '' : $adresse.' – ').$zeile;
}
private static function melden(string $text): void
{
$wurzel = self::$wurzel ?? dirname(__DIR__, 2);
$datei = $wurzel.'/'.self::PROTOKOLL;
if (!is_dir(dirname($datei))) {
@mkdir(dirname($datei), 0o775, true);
}
if (is_file($datei) && filesize($datei) > self::GRENZE) {
@rename($datei, $datei.'.1');
}
// Zeilenumbrüche aus Meldungen würden das Protokoll zerreißen; eine Zeile je Ereignis.
$text = str_replace(["\r", "\n"], ' ', $text);
@file_put_contents($datei, sprintf("[%s UTC] %s\n", gmdate('Y-m-d H:i:s'), mb_substr($text, 0, 4000)), FILE_APPEND | LOCK_EX);
}
/** Die Seite für Besucher. Ohne Details, ohne Abhängigkeiten, ohne fremde Quellen. */
private static function seite(): void
{
if (self::$gezeigt || PHP_SAPI === 'cli') {
return;
}
self::$gezeigt = true;
while (ob_get_level() > 0) {
@ob_end_clean();
}
if (!headers_sent()) {
http_response_code(503);
foreach (self::KOPFZEILEN as $name => $wert) {
header($name.': '.$wert);
}
}
echo self::html();
}
/** Die Wartungsseite als Text, auch für den Fehler-Listener im Framework. */
public static function html(): string
{
return '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
.'<title>Gleich wieder da</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:17px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#f4f6f5;color:#1f2d27}'
.'main{max-width:460px;padding:32px;text-align:center}h1{font-size:26px;line-height:1.25;margin:0 0 12px}p{margin:0;color:#46514c}</style></head>'
.'<body><main><h1>Die Website ist gleich wieder da.</h1><p>Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.</p>'
.'<p lang="en" style="margin-top:14px;font-size:14px">This website will be back shortly.</p></main></body></html>';
}
}
+25 -22
View File
@@ -1,6 +1,7 @@
{% extends 'admin/shell.html.twig' %} {% extends 'admin/shell.html.twig' %}
{% set nav = 'system' %} {% set nav = 'system' %}
{% block title %}System{% endblock %} {% block title %}System{% endblock %}
{% block scripts %}<script type="module" src="/assets/system-update.js?v={{ kant_version() }}"></script>{% endblock %}
{% block heading %}System und Update{% endblock %} {% block heading %}System und Update{% endblock %}
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %} {% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
{% block content %} {% block content %}
@@ -137,10 +138,17 @@
</div> </div>
{% endif %} {% endif %}
<div class="card"> {# Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Beim Öffnen fragt die Seite
selbst nach einer neuen Fassung, zeigt die Hinweise dazu und einen Knopf. Danach laufen die
Schritte sichtbar durch - bis „abgeschlossen“ oder „zurückgeschaltet“. Ohne Skript bleiben die
Formulare darunter. #}
<div class="card update-card" id="update" data-update
data-installiert="{{ state.version }}" data-csrf="{{ csrf_token('system') }}"
data-pruefen="{{ path('admin_update_check') }}" data-status="{{ path('admin_update_status') }}"
data-starten="{{ path('admin_update_start') }}" data-abschliessen="{{ path('admin_update_finish') }}">
<div class="card-head"> <div class="card-head">
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2> <h2>Update</h2>
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span> <span class="muted">Installiert <strong data-update-installiert>{{ state.version }}</strong></span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if archivePending %} {% if archivePending %}
@@ -150,35 +158,30 @@
<input type="hidden" name="finish" value="1"> <input type="hidden" name="finish" value="1">
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button> <button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
</form> </form>
{% else %}
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
<details class="hint">
<summary>Was unberührt bleibt</summary>
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</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.</p>
</details>
{% 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> – dauert einen Moment.</p>
{% endif %} {% endif %}
<div class="update-live" data-update-live role="status" aria-live="polite" hidden></div>
<details class="hint update-manual">
<summary>Weitere Möglichkeiten</summary>
<p class="field-help">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungsseite an, Dateien tauschen, Datenbank anpassen, prüfen. Unberührt bleiben <code>var/</code>, <code>.env</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
<form action="{{ path('admin_system_archive') }}" method="post"> <form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="fields"> <div class="fields">
<div> <div>
<label for="archive-target">Fassung</label> <label for="archive-target">Bestimmte Fassung</label>
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+"> <input id="archive-target" name="target" maxlength="20" placeholder="1.4.2" pattern="[0-9]+\.[0-9]+\.[0-9]+">
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p> <p class="field-help">Leer lassen für die neueste veröffentlichte Fassung. Dieser Weg funktioniert auch ohne JavaScript.</p>
</div> </div>
</div> </div>
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button> <button class="secondary" type="submit">Update aus dem Archiv einspielen</button>
</form> </form>
{% endif %} </details>
</div> </div>
</div> </div>
<dialog id="update-dialog" class="update-dialog" aria-labelledby="update-dialog-title">
<h2 id="update-dialog-title">Auf <span data-update-ziel></span> aktualisieren?</h2>
<p>Vorher werden Datenbank und Medien automatisch gesichert. Die Website zeigt etwa eine Minute lang eine Wartungsseite. Schlägt etwas fehl, wird automatisch auf {{ state.version }} zurückgeschaltet.</p>
<div class="dialog-actions"><button type="button" class="secondary" data-update-abbrechen>Abbrechen</button><button type="button" class="primary" data-update-los>Update starten</button></div>
</dialog>
{% if state.worktree %} {% if state.worktree %}
<div class="card"> <div class="card">
+1
View File
@@ -70,6 +70,7 @@
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %} {% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %} {% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %} {% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsConsent|default(false) and not editing|default(false) %}<script src="/assets/consent.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %} {% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #} show the layout, not run code that could change the page under the editor's hands. #}
+83 -1
View File
@@ -2,14 +2,24 @@
namespace App\Tests; namespace App\Tests;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory; use App\Infrastructure\ConnectionFactory;
use App\Ops\ArchiveUpdate; use App\Ops\ArchiveUpdate;
use App\Ops\Backup; use App\Ops\Backup;
use App\Ops\CommandRunner; use App\Ops\CommandRunner;
use App\Ops\Download; use App\Ops\Download;
use App\Ops\Installation;
use App\Ops\Updater; use App\Ops\Updater;
use App\Security\CmsUser;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
/** /**
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git. * Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
@@ -272,6 +282,77 @@ final class ArchiveUpdateTest extends TestCase
} }
} }
public function testTheProgressFileFollowsTheUpdateAndAFinishedOneSaysSo(): void
{
// Die Systemseite fragt diesen Fortschritt ab, während eine andere Anfrage das Update
// ausführt (ADR 113) - Phase, Meldung, von welcher auf welche Fassung.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$updater = $this->updater();
self::assertNull($updater->status());
self::assertSame('Fortschritt live.', $updater->latest()['notes'] ?? null, 'Die Hinweise der Releaseseite gehören zur neuen Fassung.');
$ergebnis = $updater->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
$status = $updater->status();
self::assertSame(['migrieren', true, '1.0.0', '1.1.0'], [$status['phase'], $status['laeuft'], $status['von'], $status['auf']]);
$fertig = $this->updater()->finish($this->installation());
self::assertTrue($fertig['ok'], $this->explain($fertig));
self::assertSame(['fertig', false], [$this->updater()->status()['phase'], $this->updater()->status()['laeuft']]);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void
{
// Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte
// zurück - Code, public/ und Datenbank -, und erst dann geht die Wartungsseite aus.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$this->db->insert('website', ['id' => 'default', 'name' => 'Vorher', 'default_locale' => 'de', 'theme' => '{}']);
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
// Etwas, das die neue Fassung in der Datenbank anrichtet, bevor die Prüfung scheitert.
$this->db->update('website', ['name' => 'Nachher'], ['id' => 'default']);
$ende = $this->updater()->finish($this->installation(), static fn (): string => 'Die Startseite antwortet mit 500.');
self::assertFalse($ende['ok']);
self::assertTrue($ende['zurueckgeschaltet'] ?? false, $this->explain($ende));
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileExists($this->root.'/src/Alt.php');
self::assertFileDoesNotExist($this->root.'/src/Neu.php');
self::assertSame("<?php // alt\n", file_get_contents($this->root.'/public/index.php'), 'Auch public/ kommt zurück.');
self::assertSame('body{color:alt}', file_get_contents($this->root.'/public/assets/site.css'));
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2');
$this->db->close();
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
self::assertSame('Vorher', $this->db->fetchOne("SELECT name FROM website WHERE id = 'default'"), 'Die Datenbank kommt aus der Sicherung vor dem Update.');
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Zurückgeschaltet heißt: die Website läuft wieder.');
self::assertNull($this->updater()->pending());
$status = $this->updater()->status();
self::assertSame(['zurueckgerollt', 'pruefen', false], [$status['phase'], $status['bei'], $status['laeuft']], 'Gescheitert ist die Prüfung - genau dieser Schritt wird markiert.');
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
}
private function installation(): Installation
{
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
$validator = new DocumentValidator();
return new Installation(
$this->db,
new PageRepository($this->db, $validator, $settings),
new PartialRepository($this->db, $validator),
new MenuRepository($this->db, $settings),
$settings,
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
$this->root,
$this->root.'/var/media',
'test-secret',
);
}
private function updater(): ArchiveUpdate private function updater(): ArchiveUpdate
{ {
$runner = new CommandRunner(); $runner = new CommandRunner();
@@ -303,7 +384,8 @@ final class ArchiveUpdateTest extends TestCase
'bin/console' => "<?php // neu\n", 'bin/console' => "<?php // neu\n",
'config/services.yaml' => "services:\n", 'config/services.yaml' => "services:\n",
'templates/base.html.twig' => 'neu', 'templates/base.html.twig' => 'neu',
'migrations.php' => "<?php return ['neu'];\n", 'migrations.php' => "<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n",
'migrations/.keep' => '',
'cli-config.php' => "<?php // neu\n", 'cli-config.php' => "<?php // neu\n",
'composer.json' => '{"name":"neu"}', 'composer.json' => '{"name":"neu"}',
'composer.lock' => '{"packages":["neu"]}', 'composer.lock' => '{"packages":["neu"]}',
+39 -6
View File
@@ -1618,7 +1618,10 @@ final class CmsTest extends TestCase
// Und der Vordereingang verrät nicht, welche PHP-Fassung hier läuft. // Und der Vordereingang verrät nicht, welche PHP-Fassung hier läuft.
$index = (string) file_get_contents(__DIR__.'/../public/index.php'); $index = (string) file_get_contents(__DIR__.'/../public/index.php');
self::assertStringContainsString("header_remove('X-Powered-By')", $index); self::assertStringContainsString("header_remove('X-Powered-By')", $index);
self::assertStringContainsString('Header always unset X-Powered-By', (string) file_get_contents(__DIR__.'/../public/.htaccess')); $htaccess = (string) file_get_contents(__DIR__.'/../public/.htaccess');
self::assertStringContainsString('Header always unset X-Powered-By', $htaccess);
// Ohne diese Zeile schreibt IONOS index.php endlos auf sich selbst um: weißer 500er (ADR 114).
self::assertStringContainsString("RewriteBase /\nRewriteRule ^index\\.php$ - [L]\nRewriteCond %{REQUEST_FILENAME} !-f", $htaccess);
} }
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
@@ -4543,8 +4546,10 @@ Achern']);
self::assertStringContainsString('value="all"', $html); self::assertStringContainsString('value="all"', $html);
self::assertStringContainsString('value="none"', $html); self::assertStringContainsString('value="none"', $html);
self::assertStringContainsString('/datenschutz', $html); self::assertStringContainsString('/datenschutz', $html);
// No script anywhere: the strict policy stays as it is. // Seit ADR 115 kommt consent.js dazu, solange die Frage offen ist - als Datei von hier,
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy')); // nie inline. Ohne Skript bleibt das Formular mit Weiterleitung.
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']); $saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
self::assertSame(302, $saved->getStatusCode()); self::assertSame(302, $saved->getStatusCode());
@@ -4558,6 +4563,9 @@ Achern']);
// With a decision in hand the banner is gone, but the way back to it is not. // With a decision in hand the banner is gone, but the way back to it is not.
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]); $decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
self::assertStringNotContainsString('consent-bar', $decided->getContent()); self::assertStringNotContainsString('consent-bar', $decided->getContent());
// Nichts mehr offen: kein Skript, die Richtlinie wieder streng.
self::assertStringNotContainsString('consent.js', $decided->getContent());
self::assertStringContainsString("script-src 'none'", (string) $decided->headers->get('Content-Security-Policy'));
self::assertStringContainsString('/einwilligung', $decided->getContent()); self::assertStringContainsString('/einwilligung', $decided->getContent());
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode()); self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
} }
@@ -4598,10 +4606,35 @@ Achern']);
self::assertStringNotContainsString('<iframe', $html); self::assertStringNotContainsString('<iframe', $html);
self::assertStringNotContainsString('karten.example/einbetten', $html); self::assertStringNotContainsString('karten.example/einbetten', $html);
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.'); self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
self::assertStringContainsString("frame-src 'none'", $policy); // Die Erlaubnis steht schon in der Richtlinie (ADR 115): consent.js setzt den Rahmen
// nach der Einwilligung ohne Neuladen ein, und eine Richtlinie ändert sich danach nicht
// mehr. Geladen wird dadurch nichts - es gibt keinen Rahmen und keine Adresse.
self::assertStringContainsString('frame-src https://karten.example', $policy);
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
} }
} }
public function testTheConsentScriptSavesWithoutARedirect(): void
{
$this->askConsent();
// consent.js kennzeichnet seine Anfrage; dann gibt es das Cookie ohne Weiterleitung.
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/'], [], ['HTTP_X_KANT_CONSENT' => '1']);
self::assertSame(204, $saved->getStatusCode());
self::assertNull($saved->headers->get('Location'));
self::assertSame('', $saved->getContent());
$cookie = $saved->headers->getCookies()[0];
self::assertSame(Consent::COOKIE, $cookie->getName());
self::assertStringContainsString('external', (string) $cookie->getValue());
// Von einer fremden Website bleibt es bei der Antwort ohne Cookie, wie beim Formular.
$fremd = $this->request('/einwilligung', 'POST', ['choice' => 'all'], [], ['HTTP_X_KANT_CONSENT' => '1', 'HTTP_SEC_FETCH_SITE' => 'cross-site']);
self::assertSame([], $fremd->headers->getCookies());
// Ohne die Kopfzeile bleibt alles beim Alten: Weiterleitung zurück.
self::assertSame(302, $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/'])->getStatusCode());
}
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
{ {
$document = DemoContent::starter('Mit Karte'); $document = DemoContent::starter('Mit Karte');
@@ -4710,7 +4743,7 @@ Achern']);
} }
} }
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response private function request(string $url, string $method = 'GET', array $body = [], array $cookies = [], array $server = []): Response
{ {
$previous = []; $previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) { foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
@@ -4719,7 +4752,7 @@ Achern']);
} }
$kernel = new Kernel('test', false); $kernel = new Kernel('test', false);
try { try {
$request = Request::create($url, $method, $body, $cookies); $request = Request::create($url, $method, $body, $cookies, [], $server);
$response = $kernel->handle($request); $response = $kernel->handle($request);
// Counting happens on kernel.terminate, so a test that does not terminate would // Counting happens on kernel.terminate, so a test that does not terminate would
// silently test a different application than the one that runs. // silently test a different application than the one that runs.
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Tests;
use App\EventSubscriber\ErrorLogSubscriber;
use App\Ops\Notfall;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Kein leerer 500er mehr (ADR 113).
*
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach eine weiße Seite auf jeder
* Adresse und kein Hinweis darauf, warum. Seitdem sehen Besucher eine Wartungsseite mit 503, und
* der Grund steht in var/log/notfall.log.
*/
final class NotfallTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-notfall-'.bin2hex(random_bytes(6));
mkdir($this->root, 0o775, true);
Notfall::einschalten($this->root);
}
protected function tearDown(): void
{
foreach (glob($this->root.'/var/log/*') ?: [] as $datei) {
unlink($datei);
}
@rmdir($this->root.'/var/log');
@rmdir($this->root.'/var');
@rmdir($this->root);
}
private function ereignis(\Throwable $fehler): ExceptionEvent
{
$kernel = $this->createStub(HttpKernelInterface::class);
return new ExceptionEvent($kernel, Request::create('/leistungen'), HttpKernelInterface::MAIN_REQUEST, $fehler);
}
public function testAServerErrorBecomesAMaintenancePageAndALogLine(): void
{
$ursache = new \PDOException('file is not a database');
$ereignis = $this->ereignis(new \RuntimeException('Abfrage fehlgeschlagen', 0, $ursache));
(new ErrorLogSubscriber(debug: false))->onException($ereignis);
$antwort = $ereignis->getResponse();
self::assertNotNull($antwort);
self::assertSame(503, $antwort->getStatusCode(), 'Für Suchmaschinen: gleich wieder da, nicht kaputt.');
self::assertSame('300', $antwort->headers->get('Retry-After'));
self::assertStringContainsString('Die Website ist gleich wieder da.', (string) $antwort->getContent());
self::assertStringNotContainsString('database', (string) $antwort->getContent(), 'Einzelheiten gehören ins Protokoll, nicht auf die Seite.');
$protokoll = (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL);
self::assertStringContainsString('GET /leistungen – RuntimeException: Abfrage fehlgeschlagen', $protokoll);
self::assertStringContainsString('Ursache: PDOException: file is not a database', $protokoll, 'Die eigentliche Ursache steckt eine Ebene tiefer.');
}
public function testANotFoundIsNoEmergencyAndDevelopmentKeepsItsDetailedPage(): void
{
$nichtGefunden = $this->ereignis(new NotFoundHttpException('Gibt es nicht'));
(new ErrorLogSubscriber(debug: false))->onException($nichtGefunden);
self::assertNull($nichtGefunden->getResponse());
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
// „Zugriff verweigert“ führt zur Anmeldung - die Sicherheitsschicht entscheidet, nicht der Notfall.
$verweigert = $this->ereignis(new \Symfony\Component\Security\Core\Exception\AccessDeniedException());
(new ErrorLogSubscriber(debug: false))->onException($verweigert);
self::assertNull($verweigert->getResponse());
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
$entwicklung = $this->ereignis(new \LogicException('Fehler beim Entwickeln'));
(new ErrorLogSubscriber(debug: true))->onException($entwicklung);
self::assertNull($entwicklung->getResponse(), 'Im Entwicklungsmodus bleibt die ausführliche Fehlerseite.');
self::assertStringContainsString('LogicException: Fehler beim Entwickeln', (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL));
}
public function testTheFrontControllerLoadsTheEmergencyLayerBeforeAnythingElse(): void
{
$einstieg = (string) file_get_contents(dirname(__DIR__).'/public/index.php');
self::assertLessThan(strpos($einstieg, 'config/bootstrap.php'), strpos($einstieg, 'Notfall::einschalten'), 'Scheitert schon der Start, muss die Notfallschicht stehen.');
self::assertStringContainsString('catch (Throwable $fehler)', $einstieg);
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ final class ReleaseOnDisk extends Download
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */ /** 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 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($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version, 'name' => 'KantSites '.$version, 'body' => 'Fortschritt live.'], 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', $base, $version, $version), $archiv);
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf( $this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
"%s kantsites-%s.zip\n", "%s kantsites-%s.zip\n",
+32
View File
@@ -306,6 +306,38 @@ final class SystemScreenTest extends WebTestCase
} }
} }
public function testTheOneClickUpdateEndpointsAreForAdministratorsOnly(): void
{
// Der Fortschritt für die Systemseite (ADR 113): Redakteure sehen ihn nicht, ein Start
// braucht das Token der Sitzung, ein Abschluss den Einmalschlüssel des laufenden Updates.
$redaktion = $this->login('editor');
$redaktion->request('GET', '/admin/system/update/status');
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$admin = $this->login();
$admin->request('GET', '/admin/system/update/status');
self::assertSame(200, $admin->getResponse()->getStatusCode());
self::assertArrayHasKey('installiert', json_decode((string) $admin->getResponse()->getContent(), true));
$admin->request('POST', '/admin/system/update/starten', [], [], ['HTTP_X_CSRF_TOKEN' => 'erfunden']);
self::assertSame(403, $admin->getResponse()->getStatusCode());
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'egal']);
self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent(), 'Ohne offenes Update gibt es nichts abzuschließen.');
$marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE;
@mkdir(dirname($marke), 0o775, true);
file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('b', 32)]));
try {
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'falsch']);
self::assertSame(403, $admin->getResponse()->getStatusCode());
self::assertFileExists($marke);
} finally {
@unlink($marke);
}
}
public function testNobodyButAnAdministratorCanStartAMigration(): void public function testNobodyButAnAdministratorCanStartAMigration(): void
{ {
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm. // Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.