Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2497b8cb69 | ||
|
|
f5b9ae03e3 | ||
|
|
d313156c73 | ||
|
|
090436233d | ||
|
|
8b212d5cc9 | ||
|
|
daad5a1740 | ||
|
|
dd5bc16413 | ||
|
|
bb9d7c6b45 | ||
|
|
be4a4f23d8 | ||
|
|
32e42f973b | ||
|
|
a57b829a15 |
No files matched your search
@@ -188,6 +188,8 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
|
||||
|
||||
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
|
||||
**Inhalte zwischen Installationen übertragen** (seit 1.32.0, ADR 118): System → „Inhalte übertragen“ exportiert ein Inhaltspaket (Zip) und importiert eines – per Upload oder aus `var/pakete/`. Ersetzt werden Seiten, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen, Statistik und Turnstile-Zugangsdaten bleiben, Konten nur mit eigenem Häkchen. Vorher wird gesichert. Auf der Konsole: `php bin/console cms:paket:export` und `php bin/console cms:paket:import <datei>`.
|
||||
|
||||
## Eigene 404-Seite (ab 1.8.0)
|
||||
|
||||
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
|
||||
@@ -290,7 +292,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht selbst; unter „System“ → „HTTPS“ schaltet „HTTPS erzwingen“ die drei Zeilen in `public/.htaccess` ein – nur wenn die Adresse vorher über HTTPS hier ankommt, mit Probe danach und Rücknahme bei einer Schleife. HSTS ist ein eigener Schalter. Konsole: `php bin/console cms:https` (Stand), `cms:https an|aus|hsts-an|hsts-aus` (ADR 120).
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
@@ -397,7 +399,7 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
|
||||
|
||||
### 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 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.
|
||||
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 und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). 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/`.
|
||||
|
||||
|
||||
@@ -2,3 +2,8 @@ framework:
|
||||
mailer:
|
||||
# Tests never send real mail; the transport collects messages in memory instead.
|
||||
dsn: 'null://null'
|
||||
|
||||
parameters:
|
||||
# Das Versandprotokoll der Tests gehört nicht nach var/log der echten Installation.
|
||||
kant.mail_log: '%kernel.cache_dir%/mail.log'
|
||||
kant.mailer_dsn: 'null://null'
|
||||
@@ -10,6 +10,8 @@ parameters:
|
||||
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
|
||||
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
|
||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
|
||||
kant.mailer_dsn: 'sendmail://default'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
@@ -59,6 +61,12 @@ services:
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
|
||||
# schreiben.
|
||||
App\Ops\MailCheck:
|
||||
arguments:
|
||||
$dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
|
||||
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
@@ -90,6 +98,14 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
# HTTPS erzwingen und HSTS (ADR 120); die Probe ueber http kommt im Test als Closure herein.
|
||||
App\Ops\HttpsRedirect:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$httpProbe: null
|
||||
App\Command\HttpsCommand:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -118,6 +134,10 @@ services:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Ops\SitePackage:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
|
||||
@@ -2,6 +2,59 @@
|
||||
|
||||
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
|
||||
|
||||
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
|
||||
|
||||
**Entscheidung.** Jeder Versand geht durch `App\Ops\MailCheck`: Formular und Testmail. Die Klasse versendet über den Transport aus `MAILER_DSN`, wirft nie und schreibt je Versuch eine JSON-Zeile nach `var/log/mail.log` (per FTP lesbar, ab 256 KB nach `.1` rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, `null://`) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (`< `), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll.
|
||||
|
||||
**Systemseite.** Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token.
|
||||
|
||||
**Folgen.** Der Transport wird träge gebaut (`lazy`), damit eine ungültige `MAILER_DSN` den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in `.env`; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (`kant.mail_log`, überschreibbar mit `MAIL_LOG`). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung.
|
||||
|
||||
## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026)
|
||||
|
||||
Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife).
|
||||
|
||||
**Zwei Fehler, beim Bau gefunden und auf einem echten Apache (XAMPP 2.4) nachgestellt.**
|
||||
1. Seit 1.28.1 stand der HTTPS-Block **hinter** `RewriteRule ^index\.php$ - [L]` und der Regel für `index.php`. Nach dem Umschreiben auf `index.php` beginnt die Runde neu und endet an der ersten Regel, die Umleitung kam für Seiten nie an die Reihe: Mit eingeschalteten Zeilen antworteten `/` und `/leistungen` mit 200, nur echte Dateien (`/x.css`) mit 301. Der Block steht jetzt direkt nach `RewriteBase`; nachgemessen: alle Pfade 301, mit `X-Forwarded-Proto: https` 200 (keine Schleife hinter einem Proxy).
|
||||
2. `FilesMatch "^\."` sperrte auch `/.well-known/…`: Apache prüft den ersten nicht vorhandenen Pfadteil gegen die Regel. Die Probe `/.well-known/kantsites-origin` antwortete deshalb unter Apache mit 403 – auch die Prüfung bei „Adresse speichern“ konnte dort nie gelingen. Ausgenommen ist jetzt genau `.well-known` (`^\.(?!well-known$)`); `.env`, `.htaccess`, `.htpasswd` bleiben 403 (nachgemessen).
|
||||
|
||||
**Entscheidung.** `App\Ops\HttpsRedirect` schaltet die vorhandenen Zeilen ein und aus, unter System („HTTPS“) und als `cms:https [an|aus|hsts-an|hsts-aus]`:
|
||||
- Einschalten nur mit `SITE_ORIGIN` auf `https://` und nur, wenn die Installation dort vorher mit gültigem Zertifikat ankommt (`OriginCheck`, dieselbe Kennungsprobe wie beim Adresswechsel).
|
||||
- Danach dieselbe Probe noch einmal. Kommt sie nicht mehr an, ist es eine Schleife (der Server meldet HTTPS nicht und setzt keinen Proxy-Kopf): Die Datei wird sofort zurückgeschrieben.
|
||||
- Dann `http://<Adresse>` ohne Weiterleitungen folgen: 301/308 auf genau die https-Adresse ist der Erfolg. Antwortet http anders, liest der Server die Regel nicht (mod_rewrite, AllowOverride) – die Zeilen schaden nicht, die Meldung verweist auf die Weiterleitung des Hosters. Erreicht der Server sich selbst nicht über http, sagt die Meldung das statt zu raten.
|
||||
- HSTS ist ein eigener zweiter Schalter, nur bei eingeschalteter Umleitung und funktionierendem HTTPS, mit Warnung: Ein Browser hält den Kopf ein Jahr. „Umleitung ausschalten“ nimmt HSTS mit.
|
||||
- Darf PHP die Datei nicht schreiben oder fehlen die Zeilen, zeigt System, was von Hand zu tun ist.
|
||||
|
||||
**Updates.** Die Zusammenführung, die eingeschaltete Zeilen in die neue `.htaccess` mitnimmt, steht jetzt in `HttpsRedirect::keepEnabled()` und gilt für beide Wege: das Archiv-Update wie bisher, und neu der Git-Updater, der die Datei vorher mit `checkout --force` überschrieb. Eine geänderte `public/.htaccess` zählt im Git-Weg nicht mehr als lokale Änderung, die das Update verweigert. Weil die Zeilen nach Inhalt und nicht nach Position gefunden werden, landet eine von Hand eingeschaltete Umleitung beim Update an der neuen, richtigen Stelle.
|
||||
|
||||
**Folgen.** Keine Migration. Installationen, die die Zeilen schon von Hand eingeschaltet haben, leiteten bisher nur Dateien um; mit dem Update auf 1.33.0 leiten sie alles um.
|
||||
|
||||
## ADR 119 – Das Menü schließt nach einer Wahl (12.10.2026)
|
||||
|
||||
Anlass: Umut: „Wenn man Links zu Ankern, also #kontakt, im Menü klickt, dann schließt sich das Menü nicht beim Mobilmenü. Also immer schließen, wenn man ausgewählt hat, egal was.“
|
||||
|
||||
**Ursache.** Das aufklappbare Menü ist ein `details` (ADR 117: öffnet ohne Skript). Ein Sprungziel auf derselben Seite lädt nicht neu, also bleibt `details` offen und liegt über dem Ziel. CSS kann ein `details` bei einem Linkklick nicht schließen.
|
||||
|
||||
**Entscheidung.** `scrolled.js`, das kleine Skript des Kopfbereichs, schließt das Menü beim Klick auf einen Link darin (`initMenus`). Es kommt nur dorthin, wo es gebraucht wird: wenn ein aufklappbares Menü (Burger oder waagerecht mit Zusammenklappen) einen Link auf ein Sprungziel **dieser** Seite trägt (`#…` oder der eigene Pfad mit `#…`). Ein Link auf eine andere Seite braucht nichts: Die Seite lädt neu, und weil öffentliche Seiten `no-store` senden, stellt „Zurück“ kein offenes Menü aus dem Seiten-Cache her (im Browser geprüft). So bleibt jede Seite ohne solche Sprünge bei `script-src 'none'`, und auf Seiten, die `scrolled.js` ohnehin laden (Nach-oben-Knopf, Kopf mit Scrollfarben), kommt kein Skript dazu.
|
||||
|
||||
**Geprüft:** Im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“ und „Leistungen“ schließen das Menü und springen; „Alpakas“ und „Zurück“ zeigen es geschlossen. Die bestehenden Tests, die `script-src 'none'` auf Seiten mit aufklappbarem Menü ohne Sprungziele verlangen, bleiben unverändert grün.
|
||||
|
||||
## ADR 118 – Inhaltspakete: Export und Import ohne die Konten (12.10.2026)
|
||||
|
||||
Anlass: Umut: „Können wir sowas wie einen Importer einbauen, weil sich nach jeder Anpassung sonst das Passwort ändert? Wo man auswählen muss, ob Passwort/Benutzer überschrieben werden sollen. Gerne auch als Plugin.“ Bisher wurde eine lokal gebaute Website als ganze SQLite-Datei hochgeladen – samt Konten (neues Passwort nach jedem Neuaufbau) und ohne das, was nur live entstanden war (Anfragen, Statistik).
|
||||
|
||||
**Entscheidung: im Kern, nicht als Plugin.** Plugins führen bewusst keinen eigenen Servercode aus (ADR zum Pluginsystem; serverseitiger Code nur mit Isolation). Ein Import schreibt Datenbank und Medienordner – das gehört in den Kern, unter System, nur für Administratoren.
|
||||
|
||||
**Paket.** Zip mit `paket.json` (Art, Format 1, Fassung, Website, Konten ja/nein, Zahlen, SHA-256 von `inhalt.json`), `inhalt.json` (Zeilen je Tabelle) und `medien/` (Bildvarianten, PDFs, erzeugte Seitensymbole). JSON statt SQLite-Datei: Ein Paket geht von SQLite nach MySQL und zurück, und jede Zeile wird vor dem Schreiben geprüft. Inhalt: `page`, `revision`, `partial`, `menu`, `media`, `document`, `form`, `redirect`, `term`, `page_term`, `page_translation` und die Website (Name, Sprache, Gestaltung, Einstellungen). **Nicht** darin: Anfragen, Statistik, Aufgaben, Plugin-Stand, Migrationsliste und die Zugangsdaten zu Diensten (`turnstile`, `turnstileSiteKey`, `turnstileSecret`). Konten nur mit „Konten mitnehmen“ beim Export.
|
||||
|
||||
**Import (`App\Ops\SitePackage::import`).** Prüfen vor Schreiben: nur bekannte Einträge im Zip (keine Pfade nach oben, keine anderen Dateitypen, Größengrenzen gegen Zip-Bomben), Prüfsumme von `inhalt.json`, nur bekannte Tabellen und Spaltennamen, jedes Seiten- und Bereichsdokument durch `DocumentValidator`, Paket nicht aus einer neueren Fassung. Dann Sicherung (`vor Import`), Wartungszustand, Inhalte in einer Transaktion ersetzen (nur Spalten, die es hier gibt – ein älteres Paket passt so in ein neueres Schema), Website-Einstellungen übernehmen außer den Dienst-Zugangsdaten, Anfragen über die Formularadresse dem neuen Formular zuordnen, Medienordner ersetzen. **Konten nur mit eigenem Häkchen** („Konten und Passwörter aus dem Paket übernehmen“); ohne bleiben Benutzer und Passwörter der Website. Ein Häkchen bei einem Paket ohne Konten bricht ab, ohne etwas zu ändern.
|
||||
|
||||
**Oberfläche.** System → „Inhalte übertragen“: Export als Download (Datei wird nach dem Senden gelöscht), Import per Upload oder aus `var/pakete/` (für Pakete über der Upload-Grenze des Hostings, die angezeigt wird), Bestätigungshäkchen, Ergebnisseite mit den Schritten und einem Hinweis, falls Schriften der neuen Gestaltung auf dem Server fehlen. Konsole: `cms:paket:export [--mit-benutzern] [--ziel=…]`, `cms:paket:import <datei> [--mit-benutzern] [--ja]`.
|
||||
|
||||
**Folgen.** Das Zurückspielen einer Sicherung bleibt Konsolensache; der Import ist der Weg für Inhalte aus einer anderen Installation, mit eigener Sicherung davor. Reset-Skripte können ein Paket schreiben (zuerst `reset-huefner.sh` → `var/huefner-paket.zip`). Keine Migration.
|
||||
|
||||
## ADR 117 – Menü schließt beim Klick daneben, Netzwerk-Symbole, schlankere Symbole (12.10.2026)
|
||||
|
||||
Anlass: Umut: „Bei Mobilmenü, wenn es aufgeklappt ist, schließt es nicht, wenn man irgendwo anders hinklickt.“ Und für Hüfner: Facebook, Instagram und „Angebot anfragen“ als Menü-Buttons statt eigener Knöpfe im Kopf.
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
## Nicht geprüft – muss vor dem Umschalten passieren
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann unter System → „HTTPS“ auf „HTTPS erzwingen“ (prüft vorher und nachher, ADR 120; von Hand: die drei Zeilen in `public/.htaccess` einschalten). **Erst wenn das eine Weile läuft**, „HSTS einschalten“ – HSTS lässt sich in einem Browser ein Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
|
||||
+49
-1
@@ -1,5 +1,49 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.34.0: Testmail und Versandprotokoll in der Verwaltung (12.10.2026)
|
||||
|
||||
**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS).
|
||||
|
||||
**Erledigt (ADR 121):** `App\Ops\MailCheck` für Formular und Testmail, Protokoll `var/log/mail.log` mit Meldung, deutschem Hinweis und Serverantworten (ohne Client-Zeilen, also ohne Zugangsdaten). Systemseite: Karte „E-Mail-Versand“ mit Versandweg, Testmail an frei wählbare Adresse samt Absender, letzte zehn Versuche.
|
||||
|
||||
**Geprüft:** `MailCheckTest` (Hinweise, Zugangsdaten bleiben draußen, Versandweg ohne Passwort), `SystemScreenTest` (Testmail, ungültige Adresse, Token, nur Administratoren); PHPUnit vollständig 678 grün (eine bekannte Warnung aus `UpdaterTest`, unabhängig davon), php-cs-fixer sauber. Im Browser auf einer Wegwerfkopie der Hüfner-Datenbank mit unerreichbarem SMTP-Server: Fehlermeldung, Hinweis „nicht erreichbar“ und Eintrag unter „Letzte Versuche“ erscheinen. Ein echter Versand über einen echten Mailserver wurde nicht ausgeführt.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.34.0`, Release-Eintrag mit Archiv (3632 Dateien, 5,3 MB, gebaut aus `var/release-source-1.34.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Hüfner auf 1.34.0 aktualisieren, `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
|
||||
|
||||
## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026)
|
||||
|
||||
**Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen.
|
||||
|
||||
**Änderung (ADR 120):** System → „HTTPS“ mit „HTTPS erzwingen“, danach „HSTS einschalten“ und „Umleitung ausschalten“; Konsole `cms:https`. Eingeschaltet wird nur nach erfolgreicher Probe über HTTPS, nach dem Einschalten wird erneut geprüft und bei einer Schleife sofort zurückgenommen; die http-Antwort wird gemeldet. Mitbehoben: Der HTTPS-Block in `public/.htaccess` stand hinter der Regel für `index.php` und leitete Seiten nie um (nur Dateien), und `/.well-known/kantsites-origin` antwortete unter Apache mit 403, womit auch „Adresse speichern“ dort scheiterte. Der Git-Updater behält eingeschaltete Zeilen jetzt wie das Archiv-Update.
|
||||
|
||||
**Geprüft:** `tests/HttpsRedirectTest.php` (8 Tests: Probe davor/danach, Schleife zurückgenommen, Server ignoriert Regel, HSTS nur nach Umleitung, Update behält, Git-Updater zählt die Datei nicht als Änderung). Auf Apache 2.4 (XAMPP) mit der alten und neuen `.htaccess`: alt `/` und `/leistungen` 200, neu 301, mit `X-Forwarded-Proto: https` 200; `.well-known`-Probe 200, `.env`/`.htaccess`/`.htpasswd` 403; die http-Probe der Klasse erkennt 301, 200 und „keine Verbindung“. Im Browser (Hüfner lokal): Anzeige gesperrt ohne https-Adresse, eingeschalteter Zustand mit HSTS-Hinweis, „Umleitung ausschalten“ stellt die Datei byte-gleich wieder her. Nicht geprüft: der Erfolgsweg gegen ein echtes Zertifikat (lokal kein HTTPS) – das zeigt sich beim ersten Einschalten live.
|
||||
|
||||
**Live Hüfner:** nach dem Update auf 1.33.0 unter System → HTTPS „HTTPS erzwingen“ drücken.
|
||||
|
||||
## Fassung 1.32.1: Menü schließt nach einer Wahl (12.10.2026)
|
||||
|
||||
**Anlass:** Umut: Ein Link auf ein Sprungziel (#kontakt) im Mobilmenü ließ das Menü offen.
|
||||
|
||||
**Änderung (ADR 119):** `scrolled.js` schließt das aufgeklappte Menü beim Klick auf einen Link darin. Geladen wird es zusätzlich nur, wenn ein aufklappbares Menü Sprungziele derselben Seite enthält; Seiten ohne solche Links bleiben ohne Skript (Links auf andere Seiten laden neu, `no-store` verhindert ein offenes Menü nach „Zurück“).
|
||||
|
||||
**Geprüft:** PHPUnit 665 Tests grün, `npm test` 41 grün; im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“, „Leistungen“ schließen das Menü und springen. Bekannt und vorbestehend: `test:responsive-navigation` scheitert am Klick im Editor-Strukturbaum.
|
||||
|
||||
**Live Hüfner (12.10.2026):** Umut hat die Datenbank hochgeladen, ohne Medienordner – Bilder antworteten mit 404 (Kennungen vom letzten Neuaufbau). Lösung: `var/huefner-paket.zip` unter System → „Inhalte übertragen“ importieren; bringt die passenden Medien mit.
|
||||
|
||||
## Fassung 1.32.0: Inhaltspakete – Export und Import ohne die Konten (12.10.2026)
|
||||
|
||||
**Anlass:** Umut: Nach jedem Hochladen einer neu aufgebauten Datenbank galt live ein anderes Passwort; gewünscht ist ein Importer mit der Wahl, ob Benutzer/Passwort überschrieben werden („gerne auch als Plugin“).
|
||||
|
||||
**Neu im Kern (ADR 118):** System → „Inhalte übertragen“: Paket exportieren (Download, Konten nur mit Häkchen) und importieren (Upload oder aus `var/pakete/`, Upload-Grenze des Hostings angezeigt). Ersetzt werden Seiten mit Fassungen, Bereiche, Menüs, Medien/PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen (neu dem Formular zugeordnet), Statistik, Aufgaben, Plugin-Stand und Turnstile-Zugangsdaten bleiben; Konten nur mit eigenem Häkchen. Vorher Sicherung, währenddessen Wartung, jede Datei und jedes Dokument geprüft. Konsole: `cms:paket:export`, `cms:paket:import`. Im Kern statt als Plugin, weil Plugins keinen Servercode ausführen. `reset-huefner.sh` schreibt zuletzt `var/huefner-paket.zip`.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 665 grün (neu `SitePackageTest`: Import ersetzt Inhalte und behält Konten, Anfragen, Dienst-Zugangsdaten; Konten nur mit beiden Häkchen; neuere Fassung abgelehnt; fünf manipulierte Pakete ändern nichts – fremde Datei mit `../`, Skript als Medium, veränderter Inhalt, fremde Tabelle, ungültiges Dokument). `npm test` 41, `lint:twig`, php-cs-fixer. Durchlauf auf der Konsole: Hüfner exportiert und in eine veränderte Kopie importiert (Passwort, Anfrage, Turnstile blieben, Alpakaseite kam zurück, Medienordner ersetzt). Oberfläche in der lokalen Hüfner-Verwaltung: zu großer Upload mit Hinweis abgelehnt, „Konten übernehmen“ bei Paket ohne Konten abgebrochen, Import aus `var/pakete/` abgeschlossen, danach weiter angemeldet. `test:editor` grün inklusive axe der Systemseite – ein erster Lauf parallel zu PHPUnit scheiterte, der Wiederholungslauf allein bestand.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.32.0`, Release-Eintrag mit Archiv (3629 Dateien, 5,3 MB, gebaut aus `var/release-source-1.32.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Nächster Schritt:** huefner-gartenbau.de auf 1.32.0 aktualisieren, dann `var/huefner-paket.zip` unter System → „Inhalte übertragen“ importieren (ohne Konten-Häkchen).
|
||||
|
||||
## Fassung 1.31.0: Menü schließt beim Klick daneben, Netzwerk-Symbole; Hüfner-Kopf (12.10.2026)
|
||||
|
||||
**Anlass:** Umut: aufgeklapptes Mobilmenü schließt nicht beim Klick daneben; bei Hüfner wirkt der Kopf auf dem iPad verloren (Menü-Knopf mittig), und Facebook, Instagram und „Angebot anfragen“ sollen ins Menü.
|
||||
@@ -8,7 +52,11 @@
|
||||
|
||||
**Hüfner (nur Inhalt):** Kopf aus Logo und rechtsbündigem Menü; „Angebot anfragen“, Facebook und Instagram als Menü-Buttons. Desktop zeigt in der Zeile nur „Angebot anfragen“ (F/I stehen in der Leiste darüber), im aufgeklappten Menü stehen alle drei, die Symbole nebeneinander. Menü-Knopf auf iPad und Telefon rechts; Abstand und Schrift schrumpfen knapp über 1024 px mit, damit die Zeile nicht umbricht. Upload-Paket neu (`var/huefner-upload.zip`, braucht 1.31.0).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 657 grün; `npm test` 41; `lint:twig`; `test:menu-buttons` und `test:editor` grün inklusive axe; `test:browser` auf Hüfner grün (HTML Startseite rund 148 KB). Eigene Browserprüfung auf iPad quer und iPhone: öffnen, Menülinks bedienbar, Klick daneben schließt, kein waagerechtes Scrollen; Kopf bei 810, 1025, 1080, 1100, 1280 und 1366 px im Bild. **Bekannt:** `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – auch ohne diese Änderungen (Gegenprobe), vorbestehend.
|
||||
**Geprüft:** PHPUnit vollständig 657 grün; `npm test` 41; `lint:twig`; `test:menu-buttons` und `test:editor` grün inklusive axe; `test:browser` auf Hüfner grün (HTML Startseite rund 148 KB). Eigene Browserprüfung auf iPad quer und iPhone: öffnen, Menülinks bedienbar, Klick daneben schließt, kein waagerechtes Scrollen; Kopf bei 810, 1025, 1080, 1100, 1280 und 1366 px im Bild. **Veröffentlicht:** Tag `v1.31.0`, Release-Eintrag mit Archiv (3624 Dateien, 5,3 MB, gebaut aus `var/release-source-1.31.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt.
|
||||
|
||||
**Nächster Schritt:** huefner-gartenbau.de auf 1.31.0 aktualisieren, dann `var/huefner-upload.zip` einspielen (Reihenfolge wichtig: die Datenbank nutzt die neuen Symbole).
|
||||
|
||||
**Bekannt:** `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – auch ohne diese Änderungen (Gegenprobe), vorbestehend.
|
||||
|
||||
## Fassung 1.30.0: Härtung nach dem Sicherheitscheck (12.10.2026)
|
||||
|
||||
|
||||
+19
-8
@@ -9,18 +9,25 @@ Options -Indexes -MultiViews
|
||||
# Absicherung steht in der .htaccess von WordPress.
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
|
||||
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
|
||||
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
|
||||
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
|
||||
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Einschalten am besten in
|
||||
# der Verwaltung unter System -> "HTTPS erzwingen": dort wird vorher und nachher geprueft und
|
||||
# bei einer Schleife zurueckgenommen (ADR 120). Von Hand: die drei Zeilen einschalten - und erst
|
||||
# danach die HSTS-Zeile weiter unten, denn HSTS laesst sich in einem Browser ein Jahr lang nicht
|
||||
# zuruecknehmen.
|
||||
#
|
||||
# Die Zeilen stehen vor den Regeln fuer index.php, nicht dahinter: Nach dem Umschreiben auf
|
||||
# index.php beginnt die Runde neu, und "^index\.php$ - [L]" beendet sie, bevor eine spaetere
|
||||
# Regel an die Reihe kommt. Weiter unten griff die Umleitung nur fuer echte Dateien.
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
|
||||
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
|
||||
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
|
||||
@@ -65,13 +72,17 @@ RewriteRule ^ index.php [L]
|
||||
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
|
||||
Header always unset X-Powered-By
|
||||
Header always unset X-AspNet-Version
|
||||
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
|
||||
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben; System -> HSTS).
|
||||
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
</IfModule>
|
||||
|
||||
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
|
||||
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
||||
# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die
|
||||
# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache
|
||||
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die
|
||||
# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso.
|
||||
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
|
||||
|
||||
@@ -697,6 +697,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.https-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px }
|
||||
.card-body .https-actions .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
@@ -716,6 +718,11 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.mail-result { margin-top: 14px }
|
||||
.mail-result p { margin: 0 0 6px }
|
||||
.mail-result p:last-child { margin-bottom: 0 }
|
||||
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
|
||||
.mail-log-head { font-size: 14px; margin: 22px 0 0 }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/**
|
||||
* Das kleine Skript des Kopfbereichs: markiert die gescrollte Seite und schließt das Menü nach
|
||||
* einer Wahl (initMenus, unten).
|
||||
*
|
||||
* Marks the page as scrolled.
|
||||
*
|
||||
* A header can carry a second set of colours for the scrolled page - transparent over the hero,
|
||||
@@ -31,4 +34,26 @@ export function initScrolled(doc = document) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) initScrolled();
|
||||
/**
|
||||
* Schließt das aufgeklappte Menü, sobald darin etwas gewählt wird (ADR 119).
|
||||
*
|
||||
* Ein Sprungziel wie #kontakt bleibt auf derselben Seite - das `details` blieb offen und lag über
|
||||
* dem Ziel. Bei einem Link auf eine andere Seite stand es nach „Zurück“ (Seiten-Cache des
|
||||
* Browsers) noch offen da. Ohne Skript bleibt es beim Klappen von Hand und beim Klick daneben.
|
||||
*/
|
||||
export function initMenus(doc = document) {
|
||||
const onClick = (event) => {
|
||||
const link = event.target.closest?.('.menu-burger-body a');
|
||||
const menu = link?.closest('details.menu-burger');
|
||||
if (menu) {
|
||||
menu.open = false;
|
||||
}
|
||||
};
|
||||
doc.addEventListener('click', onClick);
|
||||
return () => doc.removeEventListener('click', onClick);
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) {
|
||||
initScrolled();
|
||||
initMenus();
|
||||
}
|
||||
@@ -25,3 +25,7 @@ if [ -f "$root/var/cacert.pem" ]; then
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
# Das Inhaltspaket für die Live-Seite (ADR 118): dort unter System → Inhalte übertragen importieren.
|
||||
# Konten bleiben dabei, wie sie live sind.
|
||||
rm -f "$root/var/huefner-paket.zip"
|
||||
"$php" bin/console cms:paket:export --ziel="$root/var/huefner-paket.zip"
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\HttpsRedirect;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:https', description: 'Shows or switches the HTTPS redirect and HSTS in public/.htaccess, with a probe before and after (ADR 120).')]
|
||||
final class HttpsCommand extends Command
|
||||
{
|
||||
public function __construct(private HttpsRedirect $https, private string $siteOrigin)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('aktion', InputArgument::OPTIONAL, 'an, aus, hsts-an oder hsts-aus; ohne Angabe der Stand.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$action = (string) $input->getArgument('aktion');
|
||||
if ('' === $action) {
|
||||
$state = $this->https->state();
|
||||
$io->writeln(sprintf('Umleitung auf HTTPS: %s · HSTS: %s%s', $state['redirect'] ? 'an' : 'aus', $state['hsts'] ? 'an' : 'aus', $state['found'] ? '' : ' · Zeilen fehlen in public/.htaccess'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$result = match ($action) {
|
||||
'an' => $this->https->enableRedirect($this->siteOrigin),
|
||||
'aus' => $this->https->disableRedirect(),
|
||||
'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin),
|
||||
'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin),
|
||||
default => ['ok' => false, 'message' => 'Erlaubt sind an, aus, hsts-an und hsts-aus.'],
|
||||
};
|
||||
$result['ok'] ? $io->success($result['message']) : $io->error($result['message']);
|
||||
|
||||
return $result['ok'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:paket:export', description: 'Writes a content package (pages, media, forms, design, settings) for import into another installation (ADR 118).')]
|
||||
final class PackageExportCommand extends Command
|
||||
{
|
||||
public function __construct(private SitePackage $packages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten mit Passwort-Hashes mitnehmen.')
|
||||
->addOption('ziel', null, InputOption::VALUE_REQUIRED, 'Pfad der Zip-Datei (Standard: var/pakete/paket-<website>-<zeit>.zip).');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$file = $this->packages->export((bool) $input->getOption('mit-benutzern'), $input->getOption('ziel') ?: null);
|
||||
} catch (InvalidPage $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$manifest = $this->packages->inspect($file);
|
||||
$io->success(sprintf('%s: %d Seite(n), %d Mediendatei(en), Konten %s.', $file, (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien'], $manifest['benutzer'] ? 'enthalten' : 'nicht enthalten'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:paket:import', description: 'Replaces the content of this installation with a content package; accounts stay unless --mit-benutzern (ADR 118).')]
|
||||
final class PackageImportCommand extends Command
|
||||
{
|
||||
public function __construct(private SitePackage $packages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('datei', InputArgument::REQUIRED, 'Die Zip-Datei des Pakets.')
|
||||
->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten und Passwörter aus dem Paket übernehmen.')
|
||||
->addOption('ja', null, InputOption::VALUE_NONE, 'Ohne Rückfrage ersetzen.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$file = (string) $input->getArgument('datei');
|
||||
if (!$input->getOption('ja') && !$io->confirm('Die Inhalte dieser Installation werden ersetzt (vorher wird gesichert). Weiter?', false)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$result = $this->packages->import($file, (bool) $input->getOption('mit-benutzern'));
|
||||
foreach ($result['steps'] as $step) {
|
||||
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? 'ok ' : 'FEHLER', $step['step'], $step['detail']));
|
||||
}
|
||||
|
||||
return $result['ok'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,10 @@ use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use App\Ops\MailCheck;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -29,7 +28,7 @@ final class FormController extends AbstractController
|
||||
private PageRepository $pages,
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private MailerInterface $mailer,
|
||||
private MailCheck $mail,
|
||||
private Turnstile $turnstile,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
@@ -127,15 +126,10 @@ final class FormController extends AbstractController
|
||||
if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) {
|
||||
$email->replyTo($values[$replyTo]);
|
||||
}
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
|
||||
return true;
|
||||
} catch (TransportExceptionInterface) {
|
||||
// The submission is already stored, so a broken mail setup loses no enquiry.
|
||||
// It stays marked as undelivered and is visible in the administration.
|
||||
return false;
|
||||
}
|
||||
// The submission is already stored, so a broken mail setup loses no enquiry. It stays
|
||||
// marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand).
|
||||
return $this->mail->send($email, 'Formular '.$form['slug'])['ok'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Inhaltspakete exportieren und importieren (ADR 118) - unter System, nur für Administratoren.
|
||||
*
|
||||
* Der Import ersetzt die Inhalte einer laufenden Website. Deshalb: CSRF, ein ausdrückliches
|
||||
* Häkchen, vorher eine Sicherung, während des Imports Wartung. Konten werden nur übernommen,
|
||||
* wenn man das eigens wählt - sonst bleiben Benutzer und Passwörter dieser Website.
|
||||
*/
|
||||
final class PackageController extends AbstractController
|
||||
{
|
||||
public function __construct(private SitePackage $packages, private FontLibrary $fonts, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/paket/export', name: 'admin_package_export', methods: ['POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
try {
|
||||
$file = $this->packages->export($request->request->getBoolean('benutzer'));
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
$response = new BinaryFileResponse($file, headers: ['Content-Type' => 'application/zip', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'no-store']);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, basename($file));
|
||||
// Ein Export ist zum Mitnehmen da, nicht zum Liegenlassen: er enthält alle Inhalte.
|
||||
$response->deleteFileAfterSend();
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/paket/import', name: 'admin_package_import', methods: ['POST'])]
|
||||
public function import(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$request->request->getBoolean('bestaetigt')) {
|
||||
return $this->back('Bitte bestätige, dass die Inhalte dieser Website ersetzt werden.');
|
||||
}
|
||||
$upload = $request->files->get('paket');
|
||||
$waiting = basename($request->request->getString('wartend'));
|
||||
$temporary = null;
|
||||
if ($upload instanceof UploadedFile) {
|
||||
if (!$upload->isValid()) {
|
||||
return $this->back(\in_array($upload->getError(), [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE], true)
|
||||
? 'Das Paket ist größer, als das Hosting für einen Upload erlaubt. Bitte per FTP nach var/pakete/ legen – dann erscheint es hier zur Auswahl.'
|
||||
: 'Das Hochladen ist gescheitert. Bitte erneut versuchen oder das Paket per FTP nach var/pakete/ legen.');
|
||||
}
|
||||
$temporary = $this->packages->directory().'/hochgeladen-'.bin2hex(random_bytes(6)).'.zip';
|
||||
@mkdir($this->packages->directory(), 0o775, true);
|
||||
$upload->move(\dirname($temporary), basename($temporary));
|
||||
$file = $temporary;
|
||||
} elseif ('' !== $waiting && str_ends_with($waiting, '.zip') && is_file($this->packages->directory().'/'.$waiting)) {
|
||||
$file = $this->packages->directory().'/'.$waiting;
|
||||
} else {
|
||||
return $this->back('Bitte ein Paket hochladen oder eines aus var/pakete/ wählen.');
|
||||
}
|
||||
|
||||
@set_time_limit(0);
|
||||
try {
|
||||
$result = $this->packages->import($file, $request->request->getBoolean('benutzer'));
|
||||
} finally {
|
||||
if (null !== $temporary) {
|
||||
@unlink($temporary);
|
||||
}
|
||||
}
|
||||
|
||||
// Das Ergebnis direkt, nicht über eine Weiterleitung: wurden die Konten ersetzt, gilt die
|
||||
// Anmeldung ab der nächsten Anfrage nicht mehr.
|
||||
// Schriften liegen nicht im Paket, sondern auf dem Server dieser Website (public/assets/fonts).
|
||||
// Fehlt eine, die die neue Gestaltung verlangt, sagt die Ergebnisseite, wo man sie holt.
|
||||
$missing = $result['ok'] ? $this->fonts->missing($this->theme->load()) : [];
|
||||
|
||||
return $this->render('admin/package-result.html.twig', ['result' => $result, 'fontsMissing' => $missing]);
|
||||
}
|
||||
|
||||
private function back(string $message): Response
|
||||
{
|
||||
$this->addFlash('error', $message);
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,14 @@ final class PageController extends AbstractController
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
|
||||
// Dasselbe Skript schließt das aufgeklappte Menü nach einer Wahl (ADR 119) - gebraucht nur,
|
||||
// wenn ein aufklappbares Menü auf ein Sprungziel dieser Seite zeigt: Ein Link auf eine
|
||||
// andere Seite lädt neu (die Seiten senden no-store, also stellt „Zurück“ kein offenes
|
||||
// Menü aus dem Seiten-Cache her). So bleibt eine Seite ohne solche Sprünge ohne Skript.
|
||||
$navigation = $this->navigation($path);
|
||||
$here = array_unique([rtrim($request->getPathInfo(), '/') ?: '/', $this->settings->publicPath($path, $this->settings->homePath())]);
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false)
|
||||
|| [] !== array_filter($this->collapsibleMenuSlots($documents), fn (string $slot): bool => $this->jumpsWithin($navigation[$slot] ?? [], $here));
|
||||
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
@@ -382,7 +389,7 @@ final class PageController extends AbstractController
|
||||
'canonical' => $canonical,
|
||||
'alternates' => $alternates,
|
||||
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
|
||||
'navigation' => $this->navigation($path),
|
||||
'navigation' => $navigation,
|
||||
'postLists' => $postLists,
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
@@ -774,6 +781,50 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
/** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */
|
||||
/**
|
||||
* Die Menüplätze (header, footer …) der Menüs, die als Knopf aufklappen: Burger oder waagerecht
|
||||
* mit Zusammenklappen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collapsibleMenuSlots(array $documents): array
|
||||
{
|
||||
$slots = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$slots): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('menu' === ($block['type'] ?? null)) {
|
||||
$layout = $block['props']['layout'] ?? 'horizontal';
|
||||
if ('burger' === $layout || ('horizontal' === $layout && 'never' !== ($block['props']['collapse'] ?? 'tablet'))) {
|
||||
$slots[] = (string) ($block['props']['slot'] ?? 'header');
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return array_values(array_unique($slots));
|
||||
}
|
||||
|
||||
/** Ob ein Link des Menüs zu einem Sprungziel auf dieser Seite führt (#kontakt, /#kontakt auf der Startseite). @param list<string> $here */
|
||||
private function jumpsWithin(array $links, array $here): bool
|
||||
{
|
||||
foreach ($links as $link) {
|
||||
$href = (string) ($link['href'] ?? '');
|
||||
$hash = strpos($href, '#');
|
||||
if (false !== $hash && (0 === $hash || \in_array(rtrim(substr($href, 0, $hash), '/') ?: '/', $here, true))) {
|
||||
return true;
|
||||
}
|
||||
if ($this->jumpsWithin($link['children'] ?? [], $here)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasScrolled(array $document): bool
|
||||
{
|
||||
$walk = function (array $blocks) use (&$walk): bool {
|
||||
|
||||
@@ -9,14 +9,19 @@ use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\MailCheck;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\SitePackage;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -28,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
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, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings)
|
||||
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, private SitePackage $packages, private HttpsRedirect $https, private MailCheck $mail)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +52,9 @@ final class SystemController extends AbstractController
|
||||
'asked' => $request->query->getBoolean('pruefen'),
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
'backups' => $this->backup->all(),
|
||||
// Inhaltspakete (ADR 118): die per FTP abgelegten und die Grenze für Uploads.
|
||||
'packages' => $this->packages->waiting(),
|
||||
'uploadLimit' => $this->uploadLimit(),
|
||||
'adminPath' => $this->adminPath,
|
||||
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||||
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||||
@@ -63,6 +71,12 @@ final class SystemController extends AbstractController
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||||
// HTTPS erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
|
||||
'https' => $this->https->state(),
|
||||
'secure' => $request->isSecure(),
|
||||
// E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail, die letzten Versuche.
|
||||
'mail' => $this->mailFacts(),
|
||||
'mailResult' => $request->getSession()->remove('mail_result'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -490,6 +504,73 @@ final class SystemController extends AbstractController
|
||||
), 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS erzwingen oder HSTS schalten (ADR 120). Die Umleitung wird nur eingeschaltet, wenn
|
||||
* HTTPS vorher und nachher hier ankommt; sonst bleibt public/.htaccess, wie sie war.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/https', name: 'admin_system_https', methods: ['POST'])]
|
||||
public function https(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$result = match ($request->request->getString('action')) {
|
||||
'redirect-an' => $this->https->enableRedirect($this->siteOrigin),
|
||||
'redirect-aus' => $this->https->disableRedirect(),
|
||||
'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin),
|
||||
'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin),
|
||||
default => ['ok' => false, 'message' => 'Unbekannte Aktion.'],
|
||||
};
|
||||
|
||||
return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Testmail an eine frei eingetragene Adresse (ADR 121). Das Ergebnis - Meldung, Hinweis,
|
||||
* Antworten des Servers - steht danach auf der Seite und in var/log/mail.log.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/testmail', name: 'admin_system_testmail', methods: ['POST'])]
|
||||
public function testMail(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$an = trim($request->request->getString('to'));
|
||||
$von = trim($request->request->getString('from')) ?: $an;
|
||||
foreach ([$an, $von] as $adresse) {
|
||||
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->back(sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100)));
|
||||
}
|
||||
}
|
||||
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
|
||||
$email = (new Email())
|
||||
->from(new Address($von))
|
||||
->to(new Address($an))
|
||||
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
|
||||
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).
|
||||
|
||||
Wenn sie angekommen ist, funktioniert der Versand der Formulare auf demselben Weg.
|
||||
");
|
||||
$request->getSession()->set('mail_result', ['an' => $an, 'von' => $von] + $this->mail->send($email, 'Testmail'));
|
||||
|
||||
return $this->redirect($this->generateUrl('admin_system').'#mail');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function mailFacts(): array
|
||||
{
|
||||
$route = $this->mail->route();
|
||||
|
||||
return $route + [
|
||||
'quelle' => $this->environmentSource('MAILER_DSN'),
|
||||
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
|
||||
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
|
||||
'protokoll' => $this->mail->recent(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
@@ -497,6 +578,23 @@ final class SystemController extends AbstractController
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
/** Die kleinere der beiden PHP-Grenzen, die einen Upload begrenzen - so, wie sie in der php.ini steht. */
|
||||
private function uploadLimit(): string
|
||||
{
|
||||
$bytes = static function (string $value): int {
|
||||
$value = trim($value);
|
||||
$number = (int) $value;
|
||||
|
||||
return match (strtolower(substr($value, -1))) {
|
||||
'g' => $number * 1024 ** 3, 'm' => $number * 1024 ** 2, 'k' => $number * 1024, default => $number,
|
||||
};
|
||||
};
|
||||
$limits = array_filter([$bytes((string) ini_get('upload_max_filesize')), $bytes((string) ini_get('post_max_size'))]);
|
||||
$min = [] === $limits ? 0 : min($limits);
|
||||
|
||||
return 0 === $min ? 'unbegrenzt' : number_format($min / 1048576, 0, ',', '.').' MB';
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -566,15 +566,7 @@ final class ArchiveUpdate
|
||||
*/
|
||||
private function mergeHtaccess(string $neu, string $alt): void
|
||||
{
|
||||
$vorher = (string) file_get_contents($alt);
|
||||
$inhalt = (string) file_get_contents($neu);
|
||||
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
|
||||
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
|
||||
if ($aktiv) {
|
||||
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
|
||||
}
|
||||
}
|
||||
file_put_contents($alt, $inhalt);
|
||||
file_put_contents($alt, HttpsRedirect::keepEnabled((string) file_get_contents($neu), (string) file_get_contents($alt)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Schaltet die Umleitung auf HTTPS und HSTS in public/.htaccess ein und aus (ADR 120).
|
||||
*
|
||||
* Die Zeilen stehen ausgeliefert auskommentiert in der Datei: Eine Installation ohne Zertifikat
|
||||
* liefe mit ihnen in eine Endlosschleife, und die Anwendung selbst erzwingt kein HTTPS, weil
|
||||
* PHP hinter einem Proxy oft nur http sieht. Bisher hieß das: per FTP in die .htaccess. Dieser
|
||||
* Schalter macht dasselbe aus der Verwaltung, aber nur mit Probe davor und danach:
|
||||
*
|
||||
* 1. Antwortet diese Installation unter https://<Adresse> mit gültigem Zertifikat? Sonst nichts.
|
||||
* 2. Zeilen einschalten.
|
||||
* 3. Kommt https://<Adresse> weiterhin hier an? Sonst ist es eine Schleife (der Server meldet
|
||||
* HTTPS nicht, kein Proxy-Kopf) - die Datei wird sofort zurückgeschrieben.
|
||||
* 4. Leitet http://<Adresse> mit 301 auf https um? Wenn nicht, liest der Server die Regel nicht
|
||||
* (kein mod_rewrite, AllowOverride) - die Zeilen bleiben, schaden nicht, und die Meldung sagt es.
|
||||
*
|
||||
* HSTS ist ein eigener Schritt: Ein Browser merkt sich den Kopf ein Jahr lang, ein Rückweg auf
|
||||
* http ist dann für jeden Besucher zu, der die Seite einmal gesehen hat.
|
||||
*/
|
||||
class HttpsRedirect
|
||||
{
|
||||
public const REDIRECT_LINES = [
|
||||
'RewriteCond %{HTTPS} !=on',
|
||||
'RewriteCond %{HTTP:X-Forwarded-Proto} !=https',
|
||||
'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]',
|
||||
];
|
||||
public const HSTS_LINE = 'Header always set Strict-Transport-Security';
|
||||
|
||||
/** @var \Closure(string): array{status: int, location: string, error: string} */
|
||||
private \Closure $httpProbe;
|
||||
|
||||
/** @param \Closure(string): array{status: int, location: string, error: string}|null $httpProbe */
|
||||
public function __construct(private string $projectDir, private OriginCheck $originCheck, ?\Closure $httpProbe = null)
|
||||
{
|
||||
$this->httpProbe = $httpProbe ?? self::probeHttp(...);
|
||||
}
|
||||
|
||||
public function file(): string
|
||||
{
|
||||
return $this->projectDir.'/public/.htaccess';
|
||||
}
|
||||
|
||||
/** @return array{redirect: bool, hsts: bool, found: bool, writable: bool} */
|
||||
public function state(): array
|
||||
{
|
||||
$content = is_file($this->file()) ? (string) file_get_contents($this->file()) : '';
|
||||
|
||||
return [
|
||||
'redirect' => self::active($content, self::REDIRECT_LINES),
|
||||
'hsts' => self::active($content, [self::HSTS_LINE]),
|
||||
'found' => self::present($content, self::REDIRECT_LINES) && self::present($content, [self::HSTS_LINE]),
|
||||
'writable' => is_file($this->file()) && is_writable($this->file()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet die Umleitung ein. Gibt Erfolg und eine Meldung in einem Satz oder zwei zurück;
|
||||
* bei Misserfolg ist die Datei so, wie sie vorher war.
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
public function enableRedirect(string $origin): array
|
||||
{
|
||||
$origin = rtrim(trim($origin), '/');
|
||||
if (!preg_match('#^https://[^/?\#]+$#', $origin)) {
|
||||
return ['ok' => false, 'message' => 'Erst die Adresse der Website mit https:// eintragen – die Umleitung braucht ein Ziel.'];
|
||||
}
|
||||
if (null !== $problem = $this->originCheck->problem($origin)) {
|
||||
return ['ok' => false, 'message' => $problem.' Ohne funktionierendes HTTPS bleibt die Umleitung aus.'];
|
||||
}
|
||||
$before = $this->read();
|
||||
if (null === $before) {
|
||||
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
|
||||
}
|
||||
if (!self::present($before, self::REDIRECT_LINES)) {
|
||||
return ['ok' => false, 'message' => 'Die Zeilen für HTTPS stehen nicht in public/.htaccess (geändert oder sehr alte Fassung). Das nächste Update bringt sie zurück.'];
|
||||
}
|
||||
if (self::active($before, self::REDIRECT_LINES)) {
|
||||
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist schon eingeschaltet.'];
|
||||
}
|
||||
if (!$this->write(self::switchLines($before, self::REDIRECT_LINES, true))) {
|
||||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben. Die drei Zeilen unter „HTTPS erzwingen“ dort von Hand einschalten (das # davor entfernen).'];
|
||||
}
|
||||
if (null !== $problem = $this->originCheck->problem($origin)) {
|
||||
$this->write($before);
|
||||
|
||||
return ['ok' => false, 'message' => 'Mit eingeschalteter Umleitung kam '.$origin.' nicht mehr an: '.rtrim($problem, '.').'. Vermutlich meldet der Server HTTPS nicht an die .htaccess (Proxy ohne X-Forwarded-Proto). Die Datei ist zurückgesetzt, nichts geändert.'];
|
||||
}
|
||||
$http = 'http://'.substr($origin, 8).OriginCheck::PATH;
|
||||
$answer = ($this->httpProbe)($http);
|
||||
$target = $origin.OriginCheck::PATH;
|
||||
if (in_array($answer['status'], [301, 308], true) && $answer['location'] === $target) {
|
||||
return ['ok' => true, 'message' => 'HTTPS wird jetzt erzwungen: http:// leitet mit 301 auf '.$origin.' um. HSTS erst einschalten, wenn das ein paar Tage gut läuft.'];
|
||||
}
|
||||
if (0 === $answer['status']) {
|
||||
return ['ok' => true, 'message' => 'Die Umleitung ist eingeschaltet, und HTTPS antwortet weiter. Ob http:// umleitet, ließ sich vom Server aus nicht prüfen ('.$answer['error'].') – einmal im Browser http:// aufrufen.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => sprintf('Die Zeilen sind eingeschaltet, aber http:// antwortet mit %d%s statt mit 301 auf HTTPS. Der Server liest die Umleitung offenbar nicht (mod_rewrite, AllowOverride) – beim Hoster die HTTPS-Weiterleitung einschalten.', $answer['status'], '' !== $answer['location'] ? ' nach '.$answer['location'] : '')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet die Umleitung aus - und HSTS mit, denn HSTS ohne Umleitung verspricht etwas,
|
||||
* das der Server nicht mehr einhält.
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
public function disableRedirect(): array
|
||||
{
|
||||
$before = $this->read();
|
||||
if (null === $before) {
|
||||
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
|
||||
}
|
||||
$hsts = self::active($before, [self::HSTS_LINE]);
|
||||
$after = self::switchLines(self::switchLines($before, self::REDIRECT_LINES, false), [self::HSTS_LINE], false);
|
||||
if ($after !== $before && !$this->write($after)) {
|
||||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist ausgeschaltet.'.($hsts ? ' HSTS ebenfalls – Browser, die den Kopf schon kennen, bleiben aber bis zu einem Jahr bei HTTPS.' : '')];
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, message: string} */
|
||||
public function switchHsts(bool $on, string $origin): array
|
||||
{
|
||||
$before = $this->read();
|
||||
if (null === $before || !self::present($before, [self::HSTS_LINE])) {
|
||||
return ['ok' => false, 'message' => 'Die HSTS-Zeile steht nicht in public/.htaccess.'];
|
||||
}
|
||||
if ($on) {
|
||||
if (!self::active($before, self::REDIRECT_LINES)) {
|
||||
return ['ok' => false, 'message' => 'HSTS erst, wenn die Umleitung auf HTTPS eingeschaltet ist.'];
|
||||
}
|
||||
if (null !== $problem = $this->originCheck->problem(rtrim(trim($origin), '/'))) {
|
||||
return ['ok' => false, 'message' => $problem.' HSTS bleibt aus.'];
|
||||
}
|
||||
}
|
||||
if (!$this->write(self::switchLines($before, [self::HSTS_LINE], $on))) {
|
||||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => $on
|
||||
? 'HSTS ist eingeschaltet: Browser rufen diese Domain ein Jahr lang nur noch über HTTPS auf, Subdomains eingeschlossen.'
|
||||
: 'HSTS ist ausgeschaltet. Browser, die den Kopf schon kennen, bleiben bis zu einem Jahr bei HTTPS.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die neue .htaccess eines Updates, mit den Zeilen eingeschaltet, die in der alten
|
||||
* eingeschaltet waren. Archiv- und Git-Update rufen beide hierher.
|
||||
*/
|
||||
public static function keepEnabled(string $new, string $old): string
|
||||
{
|
||||
if (self::active($old, self::REDIRECT_LINES)) {
|
||||
$new = self::switchLines($new, self::REDIRECT_LINES, true);
|
||||
}
|
||||
if (self::active($old, [self::HSTS_LINE])) {
|
||||
$new = self::switchLines($new, [self::HSTS_LINE], true);
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/** @param list<string> $lines */
|
||||
public static function active(string $content, array $lines): bool
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
if (1 !== preg_match('/^[ \t]*'.preg_quote($line, '/').'/m', $content)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param list<string> $lines */
|
||||
private static function present(string $content, array $lines): bool
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
if (1 !== preg_match('/^[ \t]*(#[ \t]*)?'.preg_quote($line, '/').'/m', $content)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param list<string> $lines */
|
||||
private static function switchLines(string $content, array $lines, bool $on): string
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
$quoted = preg_quote($line, '/');
|
||||
$content = $on
|
||||
? (string) preg_replace('/^([ \t]*)#[ \t]*('.$quoted.')/m', '$1$2', $content)
|
||||
: (string) preg_replace('/^([ \t]*)('.$quoted.')/m', '$1#$2', $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function read(): ?string
|
||||
{
|
||||
$content = is_file($this->file()) ? @file_get_contents($this->file()) : false;
|
||||
|
||||
return false === $content ? null : $content;
|
||||
}
|
||||
|
||||
private function write(string $content): bool
|
||||
{
|
||||
return is_writable($this->file()) && false !== @file_put_contents($this->file(), $content, LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Anfrage über http, ohne Weiterleitungen zu folgen: gefragt ist nur, wohin sie zeigt.
|
||||
*
|
||||
* @return array{status: int, location: string, error: string}
|
||||
*/
|
||||
private static function probeHttp(string $url): array
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_NOBODY => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => 'KantSites-HTTPS-Check',
|
||||
]);
|
||||
$raw = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
$location = is_string($raw) && preg_match('/^Location:[ \t]*(\S+)/mi', $raw, $match) ? $match[1] : '';
|
||||
|
||||
return ['status' => $status, 'location' => $location, 'error' => '' !== $error ? $error : 'keine Antwort'];
|
||||
}
|
||||
$context = stream_context_create(['http' => ['follow_location' => 0, 'ignore_errors' => true, 'timeout' => 10, 'user_agent' => 'KantSites-HTTPS-Check']]);
|
||||
$http_response_header = [];
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
$headers = $http_response_header;
|
||||
if (false === $body && [] === $headers) {
|
||||
return ['status' => 0, 'location' => '', 'error' => 'keine Verbindung über http'];
|
||||
}
|
||||
$status = preg_match('#^HTTP/\S+\s+(\d{3})#', (string) ($headers[0] ?? ''), $match) ? (int) $match[1] : 0;
|
||||
$location = '';
|
||||
foreach ($headers as $header) {
|
||||
if (preg_match('/^Location:[ \t]*(\S+)/i', $header, $match)) {
|
||||
$location = $match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => $status, 'location' => $location, 'error' => 'keine Antwort'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\Transport\Dsn;
|
||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
/**
|
||||
* Versand mit Protokoll (ADR 121).
|
||||
*
|
||||
* huefner-gartenbau.de am 12.10.2026: zwei Anfragen „Nicht versendet“, und nirgends stand,
|
||||
* warum. Der Formularversand hat die Ausnahme verschluckt; die Ursache - sendmail, das der Hoster
|
||||
* so nicht anbietet, ein falsches SMTP-Passwort, ein Absender, den der Server nicht annimmt -
|
||||
* blieb Raten. Jetzt geht jeder Versand durch diese Klasse: Formular und Testmail aus der
|
||||
* Verwaltung. Sie schreibt eine Zeile nach var/log/mail.log (per FTP lesbar) mit Meldung, einem
|
||||
* Hinweis auf Deutsch und den Antworten des Servers.
|
||||
*
|
||||
* Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers. Was der Client schickt, enthält bei
|
||||
* AUTH LOGIN Benutzer und Passwort in Base64 - das gehört in kein Protokoll.
|
||||
*/
|
||||
final class MailCheck
|
||||
{
|
||||
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach mail.log.1. */
|
||||
private const GRENZE = 262144;
|
||||
|
||||
/**
|
||||
* Der Transport erst beim ersten Versand: Eine ungültige MAILER_DSN soll den Versand scheitern
|
||||
* lassen und im Protokoll stehen, nicht schon die Seite, die ihn anbietet.
|
||||
*/
|
||||
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf
|
||||
* keine Anfrage und keine Seite kosten.
|
||||
*
|
||||
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string}
|
||||
*/
|
||||
public function send(Email $email, string $anlass): array
|
||||
{
|
||||
$an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo()));
|
||||
try {
|
||||
$gesendet = $this->transport->send($email);
|
||||
$ergebnis = [
|
||||
'ok' => true,
|
||||
'meldung' => 'Vom Mailserver angenommen.',
|
||||
'hinweis' => self::hinweisNachErfolg($this->scheme()),
|
||||
'dialog' => self::dialog((string) $gesendet?->getDebug()),
|
||||
];
|
||||
} catch (TransportExceptionInterface $fehler) {
|
||||
$ergebnis = [
|
||||
'ok' => false,
|
||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
||||
'dialog' => self::dialog($fehler->getDebug()),
|
||||
];
|
||||
} catch (\Throwable $fehler) {
|
||||
// Eine ungültige MAILER_DSN fällt erst hier auf, nicht beim Start.
|
||||
$ergebnis = [
|
||||
'ok' => false,
|
||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
||||
'dialog' => '',
|
||||
];
|
||||
}
|
||||
$this->protokollieren(['zeit' => gmdate('Y-m-d H:i:s'), 'anlass' => $anlass, 'an' => $an, 'von' => $email->getFrom()[0]?->getAddress() ?? ''] + $ergebnis);
|
||||
|
||||
return $ergebnis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie versendet wird - ohne Passwort.
|
||||
*
|
||||
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
|
||||
*/
|
||||
public function route(): array
|
||||
{
|
||||
try {
|
||||
$dsn = Dsn::fromString($this->dsn);
|
||||
} catch (\Throwable) {
|
||||
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
|
||||
}
|
||||
$art = match ($dsn->getScheme()) {
|
||||
'smtp', 'smtps' => 'SMTP',
|
||||
'sendmail', 'sendmail+smtp' => 'sendmail des Servers',
|
||||
'native' => 'PHP mail() des Servers',
|
||||
'null' => 'kein Versand (null)',
|
||||
default => $dsn->getScheme(),
|
||||
};
|
||||
$port = $dsn->getPort();
|
||||
|
||||
return [
|
||||
'art' => $art,
|
||||
'ziel' => in_array($dsn->getScheme(), ['smtp', 'smtps'], true) ? $dsn->getHost().(null !== $port ? ':'.$port : '') : '',
|
||||
'benutzer' => (string) $dsn->getUser(),
|
||||
'gueltig' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die letzten Einträge, neueste zuerst.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function recent(int $anzahl = 10): array
|
||||
{
|
||||
if (!is_file($this->logFile)) {
|
||||
return [];
|
||||
}
|
||||
$zeilen = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$eintraege = [];
|
||||
foreach (array_reverse(array_slice($zeilen, -$anzahl)) as $zeile) {
|
||||
$eintrag = json_decode($zeile, true);
|
||||
if (is_array($eintrag)) {
|
||||
$eintraege[] = $eintrag;
|
||||
}
|
||||
}
|
||||
|
||||
return $eintraege;
|
||||
}
|
||||
|
||||
public function logFile(): string
|
||||
{
|
||||
return $this->logFile;
|
||||
}
|
||||
|
||||
/** Was als Nächstes zu tun ist, für die häufigen Fälle. Leer, wenn nichts passt. */
|
||||
public static function hinweis(string $meldung, string $scheme): string
|
||||
{
|
||||
$m = strtolower($meldung);
|
||||
|
||||
return match (true) {
|
||||
'null' === $scheme => 'MAILER_DSN steht auf null://null - es wird gar nichts verschickt.',
|
||||
(str_contains($m, 'dsn') && (str_contains($m, 'invalid') || str_contains($m, 'malformed') || str_contains($m, 'must contain'))) || (str_contains($m, 'scheme') && str_contains($m, 'not supported')) => 'MAILER_DSN in .env ist ungültig. Form: smtp://benutzer:passwort@server:587 - @ und Sonderzeichen in Benutzer und Passwort URL-kodiert (@ → %40, # → %23, / → %2F, % → %25).',
|
||||
str_contains($m, '535') || str_contains($m, 'authenticat') => 'Der Mailserver lehnt die Anmeldung ab. Benutzer (die volle Adresse) und Passwort prüfen; Sonderzeichen im Passwort URL-kodieren (# → %23, / → %2F, % → %25, @ → %40). Bei Microsoft 365 muss für das Postfach „Authentifiziertes SMTP“ eingeschaltet sein.',
|
||||
str_contains($m, 'connection could not be established') || str_contains($m, 'connection refused') || str_contains($m, 'timed out') || str_contains($m, 'getaddrinfo') || str_contains($m, 'unable to connect') => 'Der Mailserver ist von hier aus nicht erreichbar. Servername und Port prüfen (meist 587); manche Hoster sperren ausgehende Verbindungen auf bestimmten Ports.',
|
||||
str_contains($m, 'certificate') || str_contains($m, 'ssl') || str_contains($m, 'tls') => 'Die verschlüsselte Verbindung kam nicht zustande. Port 587 nutzt STARTTLS, Port 465 braucht smtps:// - Port und Schema müssen zusammenpassen, und der Servername muss zum Zertifikat passen.',
|
||||
str_contains($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Besser über SMTP mit einem echten Postfach versenden: MAILER_DSN=smtp://benutzer:passwort@server:587 in .env.',
|
||||
1 === preg_match('/\b55[0-4]\b/', $m) || str_contains($m, 'sender') || str_contains($m, 'relay') => 'Der Mailserver nimmt Absender oder Empfänger nicht an. Der Absender muss meist genau das Postfach sein, mit dem angemeldet wird.',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Angenommen heißt nicht angekommen - die üblichen Gründe dafür. */
|
||||
public static function hinweisNachErfolg(string $scheme): string
|
||||
{
|
||||
if ('null' === $scheme) {
|
||||
return self::hinweis('', 'null');
|
||||
}
|
||||
|
||||
return 'Kommt nichts an: Spam-Ordner prüfen. Der Absender sollte zur eigenen Domain gehören (SPF). Liegt das Postfach des Empfängers nicht beim Hoster (etwa Microsoft 365), darf die Domain dort nicht auch als Mail-Domain eingerichtet sein - sonst stellt der Hoster intern zu.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die Antworten des Servers aus dem SMTP-Gespräch, höchstens die letzten zwölf.
|
||||
* Symfony schreibt sie mit „< “ am Anfang, die eigenen Zeilen mit „> “.
|
||||
*/
|
||||
public static function dialog(string $debug): string
|
||||
{
|
||||
$zeilen = [];
|
||||
foreach (preg_split('/\R/', $debug) ?: [] as $zeile) {
|
||||
if (str_starts_with($zeile, '< ')) {
|
||||
$zeilen[] = $zeile;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", array_slice($zeilen, -12));
|
||||
}
|
||||
|
||||
private function scheme(): string
|
||||
{
|
||||
try {
|
||||
return Dsn::fromString($this->dsn)->getScheme();
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private static function einzeilig(string $text): string
|
||||
{
|
||||
return mb_substr(trim((string) preg_replace('/\s+/', ' ', $text)), 0, 1000);
|
||||
}
|
||||
|
||||
private function protokollieren(array $eintrag): void
|
||||
{
|
||||
$ordner = dirname($this->logFile);
|
||||
if (!is_dir($ordner)) {
|
||||
@mkdir($ordner, 0o775, true);
|
||||
}
|
||||
if (is_file($this->logFile) && filesize($this->logFile) > self::GRENZE) {
|
||||
@rename($this->logFile, $this->logFile.'.1');
|
||||
}
|
||||
@file_put_contents($this->logFile, json_encode($eintrag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)."\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Inhalte einer Website als Paket - exportieren und in eine andere Installation importieren
|
||||
* (ADR 118).
|
||||
*
|
||||
* Anlass: Eine lokal aufgebaute Website wurde bisher als ganze Datenbankdatei hochgeladen. Damit
|
||||
* kamen auch die Konten mit, und nach jeder Änderung galt live ein neues Passwort; was nur live
|
||||
* entstanden war (Anfragen, Statistik), war weg. Ein Paket trägt deshalb nur Inhalte: Seiten mit
|
||||
* allen Fassungen, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen,
|
||||
* Gestaltung und Einstellungen. Konten nur, wenn man sie ausdrücklich mitnimmt - und beim Import
|
||||
* werden sie nur übernommen, wenn man das ausdrücklich wählt.
|
||||
*
|
||||
* Das Format ist Zip mit JSON statt einer SQLite-Datei: So geht ein Paket von SQLite nach MySQL
|
||||
* und zurück, und der Import prüft jede Zeile, bevor er schreibt.
|
||||
*/
|
||||
final class SitePackage
|
||||
{
|
||||
public const FORMAT = 1;
|
||||
public const MANIFEST = 'paket.json';
|
||||
public const DATA = 'inhalt.json';
|
||||
public const MEDIA = 'medien/';
|
||||
|
||||
/** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */
|
||||
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
|
||||
|
||||
/** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */
|
||||
public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret'];
|
||||
|
||||
/** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */
|
||||
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf)|favicon-\d{1,4}\.png)$/D';
|
||||
|
||||
private const MAX_FILES = 5000;
|
||||
private const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
||||
private const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
||||
private const MAX_DATA_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private DocumentValidator $validator,
|
||||
private Backup $backup,
|
||||
private Updater $updater,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Wohin Pakete gehören, die per FTP kommen, und wo Exporte entstehen. */
|
||||
public function directory(): string
|
||||
{
|
||||
return $this->root.'/var/pakete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt ein Paket und gibt seinen Pfad zurück.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
public function export(bool $withUsers = false, ?string $target = null): string
|
||||
{
|
||||
if (!class_exists(\ZipArchive::class)) {
|
||||
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket schreiben.');
|
||||
}
|
||||
$tables = [];
|
||||
foreach (self::CONTENT_TABLES as $table) {
|
||||
$tables[$table] = $this->rows($table);
|
||||
}
|
||||
$website = $this->db->fetchAssociative('SELECT id, name, default_locale, theme, settings FROM website WHERE id = ?', ['default']);
|
||||
if (false === $website) {
|
||||
throw new InvalidPage('Diese Installation hat noch keine Website.');
|
||||
}
|
||||
// Zugangsdaten zu Diensten bleiben, wo sie sind - auch im Paket stehen sie nicht.
|
||||
$settings = json_decode((string) ($website['settings'] ?? '{}'), true) ?: [];
|
||||
foreach (self::KEEP_SETTINGS as $key) {
|
||||
unset($settings[$key]);
|
||||
}
|
||||
$website['settings'] = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$tables['website'] = [$website];
|
||||
if ($withUsers) {
|
||||
$tables['cms_user'] = $this->rows('cms_user');
|
||||
}
|
||||
$data = json_encode(['tabellen' => $tables], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$files = [];
|
||||
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
|
||||
if (1 === preg_match(self::MEDIA_NAME, $name) && is_file($this->mediaDirectory.'/'.$name)) {
|
||||
$files[] = $name;
|
||||
}
|
||||
}
|
||||
$manifest = [
|
||||
'format' => self::FORMAT,
|
||||
'art' => 'KantSites-Inhaltspaket',
|
||||
'fassung' => $this->backup->version(),
|
||||
'erstellt' => gmdate('c'),
|
||||
'website' => (string) $website['name'],
|
||||
'benutzer' => $withUsers,
|
||||
'inhalt' => array_map('count', $tables),
|
||||
'medien' => count($files),
|
||||
'sha256' => hash('sha256', $data),
|
||||
];
|
||||
|
||||
$target ??= $this->directory().'/'.$this->fileName((string) $website['name']);
|
||||
if (!is_dir(\dirname($target)) && !@mkdir(\dirname($target), 0o775, true)) {
|
||||
throw new InvalidPage('Der Ordner für Pakete ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.');
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if (true !== $zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||
throw new InvalidPage('Das Paket ließ sich nicht anlegen.');
|
||||
}
|
||||
$zip->addFromString(self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$zip->addFromString(self::DATA, $data);
|
||||
foreach ($files as $name) {
|
||||
$zip->addFile($this->mediaDirectory.'/'.$name, self::MEDIA.$name);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/** Pakete, die per FTP in var/pakete liegen - neueste zuerst. @return list<array{name: string, bytes: int, manifest: ?array}> */
|
||||
public function waiting(): array
|
||||
{
|
||||
$found = [];
|
||||
foreach (glob($this->directory().'/*.zip') ?: [] as $file) {
|
||||
try {
|
||||
$manifest = $this->inspect($file);
|
||||
} catch (InvalidPage) {
|
||||
$manifest = null;
|
||||
}
|
||||
$found[] = ['name' => basename($file), 'bytes' => (int) filesize($file), 'manifest' => $manifest, 'time' => (int) filemtime($file)];
|
||||
}
|
||||
usort($found, static fn (array $a, array $b): int => $b['time'] <=> $a['time']);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Paket, ohne etwas zu schreiben, und gibt sein Manifest zurück.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
public function inspect(string $file): array
|
||||
{
|
||||
$zip = $this->open($file);
|
||||
try {
|
||||
return $this->checkedManifest($zip);
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt ein Paket ein.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>, users: bool}
|
||||
*/
|
||||
public function import(string $file, bool $withUsers = false): array
|
||||
{
|
||||
$steps = [];
|
||||
$step = static function (string $name, bool $ok, string $detail) use (&$steps): bool {
|
||||
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
|
||||
|
||||
return $ok;
|
||||
};
|
||||
$fail = static function () use (&$steps): array {
|
||||
return ['ok' => false, 'steps' => $steps, 'users' => false];
|
||||
};
|
||||
|
||||
try {
|
||||
$zip = $this->open($file);
|
||||
} catch (InvalidPage $e) {
|
||||
$step('Paket öffnen', false, $e->getMessage());
|
||||
|
||||
return $fail();
|
||||
}
|
||||
try {
|
||||
try {
|
||||
$manifest = $this->checkedManifest($zip);
|
||||
$step('Paket prüfen', true, sprintf('„%s“ vom %s, Fassung %s, %d Seite(n), %d Mediendatei(en).', $manifest['website'], substr((string) $manifest['erstellt'], 0, 10), $manifest['fassung'], (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien']));
|
||||
$tables = $this->checkedData($zip, $manifest);
|
||||
$this->validateDocuments($tables);
|
||||
$step('Inhalte prüfen', true, 'Alle Tabellen bekannt, alle Seiten- und Bereichsdokumente gültig.');
|
||||
} catch (InvalidPage|InvalidDocument $e) {
|
||||
$step('Paket prüfen', false, $e->getMessage());
|
||||
|
||||
return $fail();
|
||||
}
|
||||
if ($withUsers && [] === ($tables['cms_user'] ?? [])) {
|
||||
$step('Konten', false, 'Das Paket enthält keine Konten. Ohne Häkchen bei „Konten übernehmen“ bleiben die Konten dieser Website.');
|
||||
|
||||
return $fail();
|
||||
}
|
||||
|
||||
try {
|
||||
$safety = $this->backup->create('vor Import');
|
||||
$step('Aktuellen Stand sichern', true, $safety['name'].' – damit lässt sich der Import zurücknehmen (cms:restore).');
|
||||
} catch (\Throwable $e) {
|
||||
$step('Aktuellen Stand sichern', false, $e->getMessage().' Ohne Sicherung wird nicht importiert.');
|
||||
|
||||
return $fail();
|
||||
}
|
||||
|
||||
$this->updater->maintenance(true);
|
||||
try {
|
||||
$media = $this->extractMedia($zip);
|
||||
$this->write($tables, $withUsers);
|
||||
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
|
||||
$moved = $this->replaceMedia($media);
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
} catch (\Throwable $e) {
|
||||
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
|
||||
|
||||
return $fail();
|
||||
} finally {
|
||||
$this->updater->maintenance(false);
|
||||
if (isset($media)) {
|
||||
$this->remove($media);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return ['ok' => true, 'steps' => $steps, 'users' => $withUsers];
|
||||
}
|
||||
|
||||
/** @throws InvalidPage */
|
||||
private function open(string $file): \ZipArchive
|
||||
{
|
||||
if (!class_exists(\ZipArchive::class)) {
|
||||
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket lesen.');
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if (!is_file($file) || true !== $zip->open($file, \ZipArchive::RDONLY)) {
|
||||
throw new InvalidPage('Das ist kein lesbares Zip-Archiv.');
|
||||
}
|
||||
// Jeder Eintrag wird geprüft, bevor irgendetwas gelesen wird: nur die drei bekannten
|
||||
// Arten, keine Pfade nach oben, keine übergroßen Dateien (Zip-Bombe).
|
||||
if ($zip->numFiles > self::MAX_FILES + 2) {
|
||||
$zip->close();
|
||||
throw new InvalidPage('Das Paket enthält zu viele Dateien.');
|
||||
}
|
||||
$total = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = (string) ($stat['name'] ?? '');
|
||||
$total += (int) ($stat['size'] ?? 0);
|
||||
$known = self::MANIFEST === $name || self::DATA === $name || self::MEDIA === $name
|
||||
|| (str_starts_with($name, self::MEDIA) && 1 === preg_match(self::MEDIA_NAME, substr($name, \strlen(self::MEDIA))));
|
||||
if (!$known || (int) ($stat['size'] ?? 0) > self::MAX_FILE_BYTES) {
|
||||
$zip->close();
|
||||
throw new InvalidPage(sprintf('„%s“ gehört nicht in ein Inhaltspaket. Es wird nicht eingespielt.', mb_substr($name, 0, 80)));
|
||||
}
|
||||
}
|
||||
if ($total > self::MAX_TOTAL_BYTES) {
|
||||
$zip->close();
|
||||
throw new InvalidPage('Das Paket ist entpackt größer als 1 GB.');
|
||||
}
|
||||
|
||||
return $zip;
|
||||
}
|
||||
|
||||
/** @throws InvalidPage */
|
||||
private function checkedManifest(\ZipArchive $zip): array
|
||||
{
|
||||
$raw = $zip->getFromName(self::MANIFEST);
|
||||
$manifest = false === $raw ? null : json_decode($raw, true);
|
||||
if (!\is_array($manifest) || ($manifest['art'] ?? null) !== 'KantSites-Inhaltspaket' || ($manifest['format'] ?? null) !== self::FORMAT) {
|
||||
throw new InvalidPage('Das ist kein KantSites-Inhaltspaket (paket.json fehlt oder passt nicht).');
|
||||
}
|
||||
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', (string) ($manifest['fassung'] ?? ''))) {
|
||||
throw new InvalidPage('Im Paket fehlt die Fassung, aus der es stammt.');
|
||||
}
|
||||
// Ein Paket aus einer neueren Fassung kann Inhalte tragen, die dieser Kern nicht kennt.
|
||||
if (version_compare((string) $manifest['fassung'], $this->backup->version(), '>')) {
|
||||
throw new InvalidPage(sprintf('Das Paket stammt aus Fassung %s, installiert ist %s. Erst diese Website aktualisieren.', $manifest['fassung'], $this->backup->version()));
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array<string, mixed>>>
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
private function checkedData(\ZipArchive $zip, array $manifest): array
|
||||
{
|
||||
$stat = $zip->statName(self::DATA);
|
||||
if (false === $stat || $stat['size'] > self::MAX_DATA_BYTES) {
|
||||
throw new InvalidPage('inhalt.json fehlt oder ist zu groß.');
|
||||
}
|
||||
$raw = (string) $zip->getFromName(self::DATA);
|
||||
if (!hash_equals((string) ($manifest['sha256'] ?? ''), hash('sha256', $raw))) {
|
||||
throw new InvalidPage('Die Prüfsumme von inhalt.json stimmt nicht. Das Paket ist beschädigt oder verändert.');
|
||||
}
|
||||
$data = json_decode($raw, true, 32);
|
||||
$tables = \is_array($data) ? ($data['tabellen'] ?? null) : null;
|
||||
if (!\is_array($tables)) {
|
||||
throw new InvalidPage('inhalt.json ist nicht lesbar.');
|
||||
}
|
||||
$allowed = [...self::CONTENT_TABLES, 'website', 'cms_user'];
|
||||
foreach ($tables as $table => $rows) {
|
||||
if (!\in_array($table, $allowed, true) || !\is_array($rows) || !array_is_list($rows)) {
|
||||
throw new InvalidPage(sprintf('Die Tabelle „%s“ gehört nicht in ein Inhaltspaket.', mb_substr((string) $table, 0, 40)));
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
if (!\is_array($row) || array_is_list($row) && [] !== $row) {
|
||||
throw new InvalidPage(sprintf('Eine Zeile der Tabelle „%s“ ist ungültig.', $table));
|
||||
}
|
||||
foreach ($row as $column => $value) {
|
||||
if (1 !== preg_match('/^[a-z_][a-z0-9_]{0,63}$/D', (string) $column) || !(null === $value || \is_scalar($value))) {
|
||||
throw new InvalidPage(sprintf('Eine Spalte der Tabelle „%s“ ist ungültig.', $table));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (self::CONTENT_TABLES as $table) {
|
||||
if (!isset($tables[$table])) {
|
||||
throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table));
|
||||
}
|
||||
}
|
||||
if (1 !== \count($tables['website'] ?? []) || 'default' !== ($tables['website'][0]['id'] ?? null)) {
|
||||
throw new InvalidPage('Im Paket fehlen die Angaben zur Website.');
|
||||
}
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/** Jede Fassung und jeder Bereich muss ein gültiges Dokument sein - kaputte Inhalte kommen nicht herein. @throws InvalidDocument|InvalidPage */
|
||||
private function validateDocuments(array $tables): void
|
||||
{
|
||||
foreach (['revision', 'partial'] as $table) {
|
||||
foreach ($tables[$table] as $row) {
|
||||
$document = json_decode((string) ($row['document'] ?? ''), true);
|
||||
if (!\is_array($document)) {
|
||||
throw new InvalidPage(sprintf('Ein Dokument der Tabelle „%s“ ist kein JSON.', $table));
|
||||
}
|
||||
// Bereiche brauchen keine Hauptüberschrift, Seiten schon - wie beim Speichern.
|
||||
$this->validator->validate($document, 'revision' === $table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ersetzt die Inhalte in einer Transaktion. */
|
||||
private function write(array $tables, bool $withUsers): void
|
||||
{
|
||||
$this->db->transactional(function () use ($tables, $withUsers): void {
|
||||
// Anfragen behalten ihr Formular: Die Kennung eines Formulars kann sich mit dem Paket
|
||||
// ändern, die Adresse (slug) bleibt - über sie werden die Anfragen neu zugeordnet.
|
||||
$slugs = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
|
||||
$slugs[$form['id']] = $form['slug'];
|
||||
}
|
||||
foreach (array_reverse(self::CONTENT_TABLES) as $table) {
|
||||
$this->db->executeStatement('DELETE FROM '.$table);
|
||||
}
|
||||
foreach (self::CONTENT_TABLES as $table) {
|
||||
$this->insert($table, $tables[$table]);
|
||||
}
|
||||
$this->mergeWebsite($tables['website'][0]);
|
||||
if ($withUsers) {
|
||||
$this->db->executeStatement('DELETE FROM cms_user');
|
||||
$this->insert('cms_user', $tables['cms_user']);
|
||||
}
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
|
||||
foreach (array_keys($slugs, $form['slug'], true) as $old) {
|
||||
$this->db->executeStatement('UPDATE form_submission SET form_id = ? WHERE form_id = ?', [$form['id'], $old]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Nur Spalten, die es hier gibt: ein Paket einer älteren Fassung kennt neuere Spalten nicht. */
|
||||
private function insert(string $table, array $rows): void
|
||||
{
|
||||
$columns = array_map(static fn ($column): string => strtolower($column->getName()), $this->db->createSchemaManager()->listTableColumns($table));
|
||||
foreach ($rows as $row) {
|
||||
$this->db->insert($table, array_intersect_key($row, array_flip($columns)));
|
||||
}
|
||||
}
|
||||
|
||||
private function mergeWebsite(array $row): void
|
||||
{
|
||||
$current = $this->db->fetchAssociative('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$settings = json_decode((string) ($row['settings'] ?? '{}'), true) ?: [];
|
||||
$own = json_decode((string) ($current['settings'] ?? '{}'), true) ?: [];
|
||||
foreach (self::KEEP_SETTINGS as $key) {
|
||||
unset($settings[$key]);
|
||||
if (\array_key_exists($key, $own)) {
|
||||
$settings[$key] = $own[$key];
|
||||
}
|
||||
}
|
||||
$values = ['name' => (string) ($row['name'] ?? ''), 'default_locale' => (string) ($row['default_locale'] ?? 'de'), 'theme' => (string) ($row['theme'] ?? '{}'), 'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
if (false === $current) {
|
||||
$this->db->insert('website', ['id' => 'default', ...$values]);
|
||||
} else {
|
||||
$this->db->update('website', $values, ['id' => 'default']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Entpackt die Medien in einen eigenen Ordner neben dem Medienordner. */
|
||||
private function extractMedia(\ZipArchive $zip): string
|
||||
{
|
||||
$target = $this->root.'/var/pakete/entpackt-'.bin2hex(random_bytes(6));
|
||||
if (!@mkdir($target, 0o775, true)) {
|
||||
throw new \RuntimeException('Ein Arbeitsordner unter var/pakete ließ sich nicht anlegen.');
|
||||
}
|
||||
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||
$name = (string) $zip->getNameIndex($i);
|
||||
$base = substr($name, \strlen(self::MEDIA));
|
||||
if (!str_starts_with($name, self::MEDIA) || 1 !== preg_match(self::MEDIA_NAME, $base)) {
|
||||
continue;
|
||||
}
|
||||
// Gelesen und selbst geschrieben, nicht extractTo: der Zielname ist geprüft, nicht der Eintrag.
|
||||
$stream = $zip->getStream($name);
|
||||
$out = false === $stream ? false : fopen($target.'/'.$base, 'wb');
|
||||
if (false === $stream || false === $out) {
|
||||
throw new \RuntimeException(sprintf('%s ließ sich nicht entpacken.', $base));
|
||||
}
|
||||
stream_copy_to_stream($stream, $out, self::MAX_FILE_BYTES);
|
||||
fclose($stream);
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
|
||||
private function replaceMedia(string $source): int
|
||||
{
|
||||
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
|
||||
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.');
|
||||
}
|
||||
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
|
||||
if (1 === preg_match(self::MEDIA_NAME, $name)) {
|
||||
@unlink($this->mediaDirectory.'/'.$name);
|
||||
}
|
||||
}
|
||||
$count = 0;
|
||||
foreach (scandir($source) ?: [] as $name) {
|
||||
if (1 === preg_match(self::MEDIA_NAME, $name) && @copy($source.'/'.$name, $this->mediaDirectory.'/'.$name)) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function summary(array $tables, bool $withUsers): string
|
||||
{
|
||||
return sprintf(
|
||||
'%d Seite(n), %d Fassung(en), %d Bereich(e), %d Mediendatei(en), %d Formular(e). Konten: %s. Anfragen und Statistik dieser Website bleiben.',
|
||||
\count($tables['page']), \count($tables['revision']), \count($tables['partial']), \count($tables['media']) + \count($tables['document']), \count($tables['form']),
|
||||
$withUsers ? 'aus dem Paket übernommen ('.\count($tables['cms_user']).')' : 'unverändert',
|
||||
);
|
||||
}
|
||||
|
||||
private function rows(string $table): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT * FROM '.$table);
|
||||
}
|
||||
|
||||
private function fileName(string $name): string
|
||||
{
|
||||
$name = strtr(mb_strtolower($name), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
|
||||
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', $name), '-') ?: 'website';
|
||||
|
||||
return 'paket-'.substr($slug, 0, 40).'-'.gmdate('Ymd-His').'.zip';
|
||||
}
|
||||
|
||||
private function remove(string $path): void
|
||||
{
|
||||
foreach (glob($path.'/*') ?: [] as $entry) {
|
||||
is_dir($entry) ? $this->remove($entry) : @unlink($entry);
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -96,7 +96,7 @@ final class Updater
|
||||
'git' => $git,
|
||||
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
|
||||
'worktree' => $arbeitskopie,
|
||||
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||
'clean' => null === $status ? null : ([] === self::dirty($status['output']) && $status['ok']),
|
||||
'log' => $this->log(),
|
||||
];
|
||||
}
|
||||
@@ -138,10 +138,10 @@ final class Updater
|
||||
}
|
||||
|
||||
$status = $this->git(['status', '--porcelain']);
|
||||
$dirty = array_values(array_filter(explode("\n", $status['output'])));
|
||||
$dirty = self::dirty($status['output']);
|
||||
$add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty
|
||||
? 'Die Arbeitskopie ist unverändert.'
|
||||
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0])));
|
||||
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim($dirty[0])));
|
||||
|
||||
$remote = $this->git(['remote', 'get-url', 'origin']);
|
||||
$add($checks, 'Herkunft', $remote['ok'], $remote['ok']
|
||||
@@ -299,8 +299,13 @@ final class Updater
|
||||
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
|
||||
}
|
||||
}
|
||||
$htaccess = $this->root.'/public/.htaccess';
|
||||
$htaccessBefore = is_file($htaccess) ? (string) file_get_contents($htaccess) : null;
|
||||
$checkout = $this->git(['checkout', '--force', $target, '--']);
|
||||
$this->restoreEnvironment();
|
||||
if (null !== $htaccessBefore && is_file($htaccess)) {
|
||||
@file_put_contents($htaccess, HttpsRedirect::keepEnabled((string) file_get_contents($htaccess), $htaccessBefore), LOCK_EX);
|
||||
}
|
||||
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
|
||||
throw new \RuntimeException('checkout');
|
||||
}
|
||||
@@ -444,6 +449,18 @@ final class Updater
|
||||
}
|
||||
|
||||
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
|
||||
/**
|
||||
* Die geänderten Dateien laut `git status --porcelain` - ohne public/.htaccess: Eine dort
|
||||
* eingeschaltete HTTPS-Umleitung nimmt apply() in die neue Fassung mit (ADR 120), sie ist
|
||||
* kein Grund, das Update zu verweigern.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function dirty(string $porcelain): array
|
||||
{
|
||||
return array_values(array_filter(explode("\n", $porcelain), static fn (string $line): bool => '' !== trim($line) && 1 !== preg_match('#^\s*M{1,2}\s+public/\.htaccess$#', rtrim($line))));
|
||||
}
|
||||
|
||||
private function restoreEnvironment(): void
|
||||
{
|
||||
foreach ($this->environmentBefore as $file => $content) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'system' %}
|
||||
{% block title %}Import{% endblock %}
|
||||
{% block heading %}{{ result.ok ? 'Import abgeschlossen' : 'Import nicht ausgeführt' }}{% endblock %}
|
||||
{% block lead %}{{ result.ok ? 'Die Inhalte des Pakets stehen jetzt auf dieser Website.' : 'Es wurde nichts ersetzt – oder der Stand davor liegt als Sicherung bereit.' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Schritte</h2></div>
|
||||
<div class="card-body">
|
||||
<ul class="check-list">
|
||||
{% for step in result.steps %}
|
||||
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
|
||||
<span><strong>{{ step.step }}</strong> <span class="visually-hidden">{{ step.ok ? 'erledigt' : 'fehlgeschlagen' }}:</span> {{ step.detail }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if result.ok and result.users %}
|
||||
<p class="notice" role="status">Die Konten kamen aus dem Paket. Ab der nächsten Seite gilt die Anmeldung mit den Zugangsdaten aus dem Paket.</p>
|
||||
{% endif %}
|
||||
{% if fontsMissing is not empty %}
|
||||
<p class="notice" role="status">Die neue Gestaltung nutzt Schriften, die auf diesem Server noch fehlen. Unter <a href="{{ path('admin_theme') }}">Design</a> einmal „Schriften auf diesen Server holen“ drücken – bis dahin zeigt die Website die Ersatzschrift.</p>
|
||||
{% endif %}
|
||||
<p><a class="button-link" href="{{ path('admin_system') }}">Zurück zu System</a> · <a class="button-link" href="/" target="_blank" rel="noopener">Website ansehen</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -64,6 +64,35 @@
|
||||
<br><span class="muted">Eingestellt in der Server-Umgebung; sie hat Vorrang vor <code>.env</code> und lässt sich nur beim Hoster ändern.</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{# HTTPS erzwingen (ADR 120): schaltet die Zeilen in public/.htaccess, mit Probe davor
|
||||
und danach. HSTS erst als zweiter Schritt. #}
|
||||
<dt>HTTPS</dt>
|
||||
<dd>
|
||||
{% if https.redirect %}
|
||||
<strong>Wird erzwungen.</strong> <span class="muted">– http:// leitet mit 301 auf HTTPS um.</span>
|
||||
{% elseif origin.adresse starts with 'https://' %}
|
||||
<span class="check-bad"><strong>Nicht erzwungen.</strong></span> <span class="muted">– die Website ist auch über http:// erreichbar, ohne Umleitung.</span>
|
||||
{% else %}
|
||||
<strong>Nicht erzwungen.</strong> <span class="muted">– erst die Adresse mit https:// eintragen.</span>
|
||||
{% endif %}
|
||||
{% if https.redirect %}<br>HSTS: {% if https.hsts %}<strong>eingeschaltet</strong> <span class="muted">– Browser bleiben ein Jahr lang bei HTTPS, Subdomains eingeschlossen.</span>{% else %}<span class="muted">aus. Erst einschalten, wenn HTTPS ein paar Tage gut läuft: Ein Browser merkt sich HSTS ein Jahr lang, das lässt sich nicht zurücknehmen.</span>{% endif %}{% endif %}
|
||||
{% if not https.found %}
|
||||
<br><span class="muted">Die Zeilen dafür stehen nicht in <code>public/.htaccess</code> – geändert oder eine alte Fassung. Das nächste Update bringt sie zurück.</span>
|
||||
{% elseif not https.writable %}
|
||||
<br><span class="muted">PHP darf <code>public/.htaccess</code> nicht schreiben. Von Hand: das <code>#</code> vor den drei Zeilen unter „HTTPS erzwingen“ entfernen.</span>
|
||||
{% else %}
|
||||
<form class="https-actions" action="{{ path('admin_system_https') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
{% if not https.redirect %}
|
||||
<button class="secondary" type="submit" name="action" value="redirect-an"{% if not (origin.adresse starts with 'https://') %} disabled{% endif %}>HTTPS erzwingen</button>
|
||||
{% else %}
|
||||
{% if not https.hsts %}<button class="secondary" type="submit" name="action" value="hsts-an">HSTS einschalten</button>{% else %}<button class="secondary" type="submit" name="action" value="hsts-aus">HSTS ausschalten</button>{% endif %}
|
||||
<button class="secondary" type="submit" name="action" value="redirect-aus">Umleitung ausschalten</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not https.redirect and origin.adresse starts with 'https://' %}<p class="field-help">Vorher wird geprüft, ob {{ origin.adresse }} mit gültigem Zertifikat hier ankommt, danach, ob sie es weiterhin tut. Klappt das nicht, bleibt alles, wie es war.</p>{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
{% else %}
|
||||
@@ -98,6 +127,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail an eine frei gewählte Adresse und die
|
||||
letzten Versuche mit Ursache - vorher stand bei „Nicht versendet“ nirgends, warum. #}
|
||||
<div class="card" id="mail">
|
||||
<div class="card-head"><h2>E-Mail-Versand</h2><span class="muted">var/log/mail.log</span></div>
|
||||
<div class="card-body">
|
||||
<dl class="facts">
|
||||
<dt>Versandweg</dt>
|
||||
<dd>
|
||||
{% if mail.gueltig %}
|
||||
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
|
||||
{% else %}
|
||||
<span class="check-bad"><strong>MAILER_DSN ist ungültig.</strong></span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>, zum Beispiel <code>smtp://info%40firma.de:passwort@smtp.ionos.de:587</code>. Das Passwort wird hier nie angezeigt.</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{% if mailResult %}
|
||||
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
|
||||
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen.' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet.' }}</strong>{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
|
||||
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
|
||||
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="origin-form" action="{{ path('admin_system_testmail') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="mail-to">Testmail senden an</label>
|
||||
<div class="origin-row">
|
||||
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" required placeholder="name@beispiel.de" value="{{ mailResult.an ?? '' }}">
|
||||
<button class="secondary" type="submit">Testmail senden</button>
|
||||
</div>
|
||||
<label for="mail-from">Absender</label>
|
||||
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ mailResult.von ?? mail.absender }}" aria-describedby="mail-from-help">
|
||||
<p class="field-help" id="mail-from-help">Am besten der Absender, der auch bei den Formularen eingetragen ist. Viele Mailserver nehmen nur das Postfach an, mit dem angemeldet wird.</p>
|
||||
</form>
|
||||
|
||||
<h3 class="mail-log-head">Letzte Versuche</h3>
|
||||
{% if mail.protokoll is empty %}
|
||||
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
|
||||
{% else %}
|
||||
<ul class="log-list">
|
||||
{% for e in mail.protokoll %}
|
||||
<li class="{{ e.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<strong>{{ e.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ e.zeit }} UTC · {{ e.anlass }} an <code>{{ e.an }}</code>{% if e.von %} von <code>{{ e.von }}</code>{% endif %}
|
||||
{% if not e.ok %}<br><code>{{ e.meldung }}</code>{% if e.hinweis %}<br><span class="muted">{{ e.hinweis }}</span>{% endif %}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
|
||||
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
|
||||
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||||
@@ -234,6 +316,44 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" id="paket">
|
||||
<div class="card-head"><h2>Inhalte übertragen</h2><span class="muted">Export und Import · var/pakete</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
|
||||
|
||||
<h3>Exportieren</h3>
|
||||
<form action="{{ path('admin_package_export') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
|
||||
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
|
||||
<button class="secondary" type="submit">Paket herunterladen</button>
|
||||
</form>
|
||||
|
||||
<h3>Importieren</h3>
|
||||
<form action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="paket-datei">Paket hochladen (.zip)</label>
|
||||
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
|
||||
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
|
||||
{% if packages is not empty %}
|
||||
<fieldset class="field-group">
|
||||
<legend>Oder ein Paket aus var/pakete/</legend>
|
||||
{% for package in packages %}
|
||||
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
|
||||
<code>{{ package.name }}</code> – {% if package.manifest %}„{{ package.manifest.website }}“, Fassung {{ package.manifest.fassung }}, {{ package.manifest.inhalt.page|default(0) }} Seiten, {{ package.manifest.medien }} Dateien{% if package.manifest.benutzer %}, mit Konten{% endif %}{% else %}<span class="muted">kein gültiges Paket</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten und Passwörter aus dem Paket übernehmen</label>
|
||||
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
|
||||
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
|
||||
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
|
||||
<button class="primary" type="submit">Paket importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||||
<div class="card-body">
|
||||
|
||||
+4
-1
@@ -1621,7 +1621,10 @@ final class CmsTest extends TestCase
|
||||
$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);
|
||||
self::assertStringContainsString("RewriteRule ^index\\.php$ - [L]\nRewriteCond %{REQUEST_FILENAME} !-f", $htaccess);
|
||||
// Die HTTPS-Umleitung steht davor, sonst erreicht sie keine Seite (ADR 120).
|
||||
self::assertLessThan(strpos($htaccess, 'RewriteRule ^index\\.php$ - [L]'), strpos($htaccess, 'RewriteRule ^ https://'));
|
||||
self::assertGreaterThan(strpos($htaccess, 'RewriteBase /'), strpos($htaccess, 'RewriteRule ^ https://'));
|
||||
}
|
||||
|
||||
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Download;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\OriginCheck;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* HTTPS erzwingen aus der Verwaltung (ADR 120): eingeschaltet wird nur, wenn HTTPS vorher und
|
||||
* nachher hier ankommt, und eine Schleife wird zurückgenommen, bevor sie jemanden aussperrt.
|
||||
*/
|
||||
final class HttpsRedirectTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $shipped;
|
||||
/** @var list<?string> */
|
||||
private array $answers = [];
|
||||
/** @var array{status: int, location: string, error: string} */
|
||||
private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => ''];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-https-'.bin2hex(random_bytes(4));
|
||||
mkdir($this->root.'/public', 0o777, true);
|
||||
$this->shipped = (string) file_get_contents(dirname(__DIR__).'/public/.htaccess');
|
||||
file_put_contents($this->root.'/public/.htaccess', $this->shipped);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->root.'/public/.htaccess');
|
||||
@rmdir($this->root.'/public');
|
||||
@rmdir($this->root);
|
||||
}
|
||||
|
||||
public function testTheShippedFileHasTheRedirectBeforeTheFrontController(): void
|
||||
{
|
||||
// Hinter "RewriteRule ^index\.php$ - [L]" kam die Umleitung für Seiten nie an die Reihe:
|
||||
// Nach dem Umschreiben auf index.php beginnt die Runde neu und endet an dieser Regel.
|
||||
$redirect = strpos($this->shipped, '#'.HttpsRedirect::REDIRECT_LINES[0]);
|
||||
$front = strpos($this->shipped, 'RewriteRule ^index\.php$ - [L]');
|
||||
self::assertNotFalse($redirect);
|
||||
self::assertNotFalse($front);
|
||||
self::assertLessThan($front, $redirect);
|
||||
self::assertSame(['redirect' => false, 'hsts' => false, 'found' => true, 'writable' => true], $this->redirect()->state());
|
||||
}
|
||||
|
||||
public function testEnablingProbesBeforeAndAfterAndSwitchesOnlyTheThreeLines(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok'];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de/');
|
||||
self::assertTrue($result['ok'], $result['message']);
|
||||
self::assertStringContainsString('leitet mit 301', $result['message']);
|
||||
$now = (string) file_get_contents($this->root.'/public/.htaccess');
|
||||
foreach (HttpsRedirect::REDIRECT_LINES as $line) {
|
||||
self::assertMatchesRegularExpression('/^'.preg_quote($line, '/').'$/m', $now);
|
||||
}
|
||||
self::assertStringContainsString('#'.HttpsRedirect::HSTS_LINE, $now, 'HSTS bleibt ein eigener Schritt.');
|
||||
self::assertSame([], $this->answers, 'Vorher und nachher geprüft.');
|
||||
|
||||
$off = $this->redirect()->disableRedirect();
|
||||
self::assertTrue($off['ok']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'), 'Aus heißt: wieder wie ausgeliefert.');
|
||||
}
|
||||
|
||||
public function testNothingChangesWithoutWorkingHttps(): void
|
||||
{
|
||||
$this->answers = [null];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('bleibt die Umleitung aus', $result['message']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
|
||||
$result = $this->redirect()->enableRedirect('http://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('https://', $result['message']);
|
||||
}
|
||||
|
||||
public function testALoopIsTakenBackAtOnce(): void
|
||||
{
|
||||
// Der Server meldet HTTPS nicht an die .htaccess: mit den Zeilen leitet auch https://
|
||||
// auf sich selbst um, die Probe danach kommt nicht mehr an.
|
||||
$this->answers = ['ok', null];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('zurückgesetzt', $result['message']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
}
|
||||
|
||||
public function testAServerThatIgnoresTheRuleIsNamed(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok'];
|
||||
$this->http = ['status' => 200, 'location' => '', 'error' => ''];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertStringContainsString('antwortet mit 200', $result['message']);
|
||||
}
|
||||
|
||||
public function testHstsOnlyAfterTheRedirect(): void
|
||||
{
|
||||
$this->answers = ['ok'];
|
||||
$result = $this->redirect()->switchHsts(true, 'https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('erst, wenn die Umleitung', $result['message']);
|
||||
|
||||
$this->answers = ['ok', 'ok', 'ok'];
|
||||
$this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertTrue($this->redirect()->switchHsts(true, 'https://www.example.de')['ok']);
|
||||
self::assertSame(['redirect' => true, 'hsts' => true, 'found' => true, 'writable' => true], $this->redirect()->state());
|
||||
|
||||
// Die Umleitung aus nimmt HSTS mit.
|
||||
$this->redirect()->disableRedirect();
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
}
|
||||
|
||||
public function testAnUpdateKeepsWhatWasSwitchedOn(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok', 'ok'];
|
||||
$this->redirect()->enableRedirect('https://www.example.de');
|
||||
$this->redirect()->switchHsts(true, 'https://www.example.de');
|
||||
$merged = HttpsRedirect::keepEnabled($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
self::assertTrue(HttpsRedirect::active($merged, HttpsRedirect::REDIRECT_LINES));
|
||||
self::assertTrue(HttpsRedirect::active($merged, [HttpsRedirect::HSTS_LINE]));
|
||||
self::assertSame($this->shipped, HttpsRedirect::keepEnabled($this->shipped, $this->shipped));
|
||||
}
|
||||
|
||||
public function testTheGitUpdaterDoesNotCallTheSwitchedFileALocalChange(): void
|
||||
{
|
||||
$dirty = new \ReflectionMethod(\App\Ops\Updater::class, 'dirty');
|
||||
self::assertSame([], $dirty->invoke(null, " M public/.htaccess\n"));
|
||||
self::assertSame([], $dirty->invoke(null, 'M public/.htaccess'), 'Auch wenn die Ausgabe vorn gekürzt ist.');
|
||||
self::assertSame([' M src/Kernel.php'], $dirty->invoke(null, " M public/.htaccess\n M src/Kernel.php\n"));
|
||||
self::assertSame(['?? public/.htaccess.bak'], $dirty->invoke(null, '?? public/.htaccess.bak'));
|
||||
}
|
||||
|
||||
private function redirect(): HttpsRedirect
|
||||
{
|
||||
$test = $this;
|
||||
$download = new class($test) extends Download {
|
||||
public function __construct(private HttpsRedirectTest $test)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
return $this->test->nextAnswer();
|
||||
}
|
||||
};
|
||||
$check = new OriginCheck('geheim', $download);
|
||||
$this->token = $check->token();
|
||||
|
||||
return new HttpsRedirect($this->root, $check, fn (string $url): array => $this->http);
|
||||
}
|
||||
|
||||
private string $token = '';
|
||||
|
||||
public function nextAnswer(): ?string
|
||||
{
|
||||
self::assertNotSame([], $this->answers, 'Mehr Proben als erwartet.');
|
||||
$answer = array_shift($this->answers);
|
||||
|
||||
return null === $answer ? null : $this->token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\MailCheck;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\Exception\TransportException;
|
||||
use Symfony\Component\Mailer\SentMessage;
|
||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
/**
|
||||
* Versand mit Protokoll (ADR 121): huefner-gartenbau.de zeigte zweimal „Nicht versendet“, und
|
||||
* nirgends stand, warum.
|
||||
*/
|
||||
final class MailCheckTest extends TestCase
|
||||
{
|
||||
private string $log;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->log = sys_get_temp_dir().'/kant-mail-'.bin2hex(random_bytes(4)).'/mail.log';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->log);
|
||||
@rmdir(dirname($this->log));
|
||||
}
|
||||
|
||||
public function testAFailedSendSaysWhyAndKeepsTheCredentialsOutOfTheLog(): void
|
||||
{
|
||||
$fehler = new TransportException('Failed to authenticate on SMTP server with username "formular@kant-it.de" using the following authenticators: "LOGIN". Authenticator "LOGIN" returned "Expected response code "235" but got code "535", with message "535 Authentication credentials invalid".');
|
||||
$fehler->appendDebug("< 220 smtp.ionos.de ESMTP\n> EHLO cms.test\n< 250 AUTH LOGIN PLAIN\n> AUTH LOGIN\n< 334 VXNlcm5hbWU6\n> Zm9ybXVsYXJAa2FudC1pdC5kZQ==\n< 334 UGFzc3dvcmQ6\n> R2VoZWltMTIz\n< 535 Authentication credentials invalid\n");
|
||||
$mail = new MailCheck($this->transport($fehler), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log);
|
||||
|
||||
$ergebnis = $mail->send($this->email(), 'Formular anfrage');
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertStringContainsString('535', $ergebnis['meldung']);
|
||||
self::assertStringContainsString('lehnt die Anmeldung ab', $ergebnis['hinweis']);
|
||||
self::assertStringContainsString('< 535 Authentication credentials invalid', $ergebnis['dialog']);
|
||||
// Was der Client schickt, bleibt draußen: bei AUTH LOGIN sind das Benutzer und Passwort.
|
||||
self::assertStringNotContainsString('R2VoZWltMTIz', $ergebnis['dialog']);
|
||||
self::assertStringNotContainsString('> ', $ergebnis['dialog']);
|
||||
|
||||
$protokoll = (string) file_get_contents($this->log);
|
||||
self::assertStringNotContainsString('R2VoZWltMTIz', $protokoll);
|
||||
self::assertStringNotContainsString('Geheim123', $protokoll);
|
||||
$eintrag = $mail->recent()[0];
|
||||
self::assertFalse($eintrag['ok']);
|
||||
self::assertSame('Formular anfrage', $eintrag['anlass']);
|
||||
self::assertSame('info@huefner-gartenbau.de', $eintrag['an']);
|
||||
self::assertSame('formular@kant-it.de', $eintrag['von']);
|
||||
}
|
||||
|
||||
public function testTheRouteNamesServerAndUserButNeverThePassword(): void
|
||||
{
|
||||
$route = (new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log))->route();
|
||||
self::assertSame(['art' => 'SMTP', 'ziel' => 'smtp.ionos.de:587', 'benutzer' => 'formular@kant-it.de', 'gueltig' => true], $route);
|
||||
|
||||
self::assertFalse((new MailCheck($this->transport(null), 'kein dsn', $this->log))->route()['gueltig']);
|
||||
self::assertSame('sendmail des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
|
||||
}
|
||||
|
||||
public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void
|
||||
{
|
||||
$mail = new MailCheck($this->transport(null), 'smtp://u:p@smtp.ionos.de:587', $this->log);
|
||||
$ergebnis = $mail->send($this->email(), 'Testmail');
|
||||
|
||||
self::assertTrue($ergebnis['ok']);
|
||||
self::assertStringContainsString('intern zu', $ergebnis['hinweis'], 'Microsoft 365 beim Kunden, Domain auch beim Hoster: der häufigste Grund für „angenommen, nie angekommen“.');
|
||||
self::assertTrue($mail->recent()[0]['ok']);
|
||||
}
|
||||
|
||||
public function testTheHintsCoverTheUsualCauses(): void
|
||||
{
|
||||
self::assertStringContainsString('nicht erreichbar', MailCheck::hinweis('Connection could not be established with host "ssl://smtp.ionos.de:465": stream_socket_client(): Unable to connect', 'smtps'));
|
||||
self::assertStringContainsString('SMTP', MailCheck::hinweis('Expected response code "220" but got an empty response.', 'sendmail'));
|
||||
self::assertStringContainsString('Absender', MailCheck::hinweis('Expected response code "250" but got code "550", with message "550 Sender address rejected".', 'smtp'));
|
||||
self::assertStringContainsString('ungültig', MailCheck::hinweis('The "foo" scheme is not supported; supported schemes for mailer "smtp" are: "smtp", "smtps".', 'foo'));
|
||||
self::assertStringContainsString('null://null', MailCheck::hinweisNachErfolg('null'));
|
||||
}
|
||||
|
||||
private function email(): Email
|
||||
{
|
||||
return (new Email())->from('formular@kant-it.de')->to('info@huefner-gartenbau.de')->subject('Anfrage')->text('Test');
|
||||
}
|
||||
|
||||
/** Ein Transport, der nichts verschickt: wirft $fehler oder meldet „angenommen“. */
|
||||
private function transport(?TransportException $fehler): TransportInterface
|
||||
{
|
||||
return new class($fehler) implements TransportInterface {
|
||||
public function __construct(private ?TransportException $fehler)
|
||||
{
|
||||
}
|
||||
|
||||
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
|
||||
{
|
||||
if (null !== $this->fehler) {
|
||||
throw $this->fehler;
|
||||
}
|
||||
$gesendet = new SentMessage($message, $envelope ?? Envelope::create($message));
|
||||
$gesendet->appendDebug("< 250 2.0.0 Ok: queued\n");
|
||||
|
||||
return $gesendet;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'test://';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\SitePackage;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Inhaltspakete zwischen zwei Installationen (ADR 118).
|
||||
*
|
||||
* Der Anlass war das Passwort, das sich nach jedem Hochladen einer Datenbank änderte. Deshalb steht
|
||||
* der Test vorn, der das festhält: Inhalte kommen, Konten, Anfragen und Zugangsdaten zu Diensten
|
||||
* bleiben - außer man nimmt die Konten ausdrücklich mit.
|
||||
*/
|
||||
final class SitePackageTest extends TestCase
|
||||
{
|
||||
/** @var array<string, array{root: string, db: Connection, package: SitePackage}> */
|
||||
private array $sites = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
foreach (['quelle', 'ziel'] as $name) {
|
||||
$root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-paket-'.$name.'-'.bin2hex(random_bytes(5));
|
||||
mkdir($root.'/var/media', 0o775, true);
|
||||
file_put_contents($root.'/VERSION', "1.32.0\n");
|
||||
$url = 'sqlite:///'.$root.'/var/cms.db';
|
||||
$db = ConnectionFactory::create($url);
|
||||
TestSchema::build($db);
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Website '.$name, 'default_locale' => 'de', 'theme' => '{}', 'settings' => json_encode(['turnstileSecret' => 'geheim-'.$name, 'turnstileSiteKey' => 'schluessel-'.$name])]);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db, new MediaLibrary($db, $root.'/var/media')));
|
||||
$pages->createPage('ziel' === $name ? 'alte-seite' : 'neue-seite', null, 'page', DemoContent::starter('Seite der '.$name));
|
||||
(new FormRepository($db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'info@example.org'], 0);
|
||||
$db->insert('cms_user', ['id' => 'u-'.$name, 'username' => $name, 'password_hash' => 'hash-'.$name, 'roles' => '["ROLE_ADMIN"]']);
|
||||
file_put_contents($root.'/var/media/'.str_repeat('ziel' === $name ? 'b' : 'a', 32).'-480.webp', 'bild '.$name);
|
||||
$runner = new CommandRunner();
|
||||
$this->sites[$name] = ['root' => $root, 'db' => $db, 'package' => new SitePackage($db, new DocumentValidator(), new Backup($root, $url, $root.'/var/media', $db, $runner), new Updater($root, $url, $runner, $db), $root, $root.'/var/media')];
|
||||
}
|
||||
// Eine Anfrage, die es nur auf der Zielwebsite gibt - sie muss den Import überstehen.
|
||||
$ziel = $this->sites['ziel']['db'];
|
||||
$ziel->insert('form_submission', ['id' => 's1', 'form_id' => $ziel->fetchOne('SELECT id FROM form'), 'data' => '{}', 'sender_hash' => 'x', 'created_at' => '2026-10-12 10:00:00', 'delivered' => 0]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->sites as $site) {
|
||||
$site['db']->close();
|
||||
}
|
||||
gc_collect_cycles();
|
||||
foreach ($this->sites as $site) {
|
||||
self::remove($site['root']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testImportReplacesContentAndKeepsAccountsSubmissionsAndServiceKeys(): void
|
||||
{
|
||||
$file = $this->sites['quelle']['package']->export();
|
||||
$result = $this->sites['ziel']['package']->import($file);
|
||||
self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$db = $this->sites['ziel']['db'];
|
||||
self::assertSame(['neue-seite'], $db->fetchFirstColumn('SELECT path FROM page'));
|
||||
self::assertSame('Website quelle', $db->fetchOne('SELECT name FROM website'));
|
||||
// Das Konto der Zielwebsite bleibt - genau der Anlass dieses Imports.
|
||||
self::assertSame([['username' => 'ziel', 'password_hash' => 'hash-ziel']], $db->fetchAllAssociative('SELECT username, password_hash FROM cms_user'));
|
||||
// Die Anfrage hängt jetzt am Formular aus dem Paket (gleiche Adresse, neue Kennung).
|
||||
self::assertSame($db->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT form_id FROM form_submission WHERE id = ?', ['s1']));
|
||||
self::assertSame($this->sites['quelle']['db']->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT id FROM form'));
|
||||
// Zugangsdaten zu Diensten bleiben, wo sie waren - und standen auch nicht im Paket.
|
||||
$settings = json_decode((string) $db->fetchOne('SELECT settings FROM website'), true);
|
||||
self::assertSame(['geheim-ziel', 'schluessel-ziel'], [$settings['turnstileSecret'], $settings['turnstileSiteKey']]);
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($file);
|
||||
self::assertStringNotContainsString('geheim-quelle', (string) $zip->getFromName(SitePackage::DATA));
|
||||
self::assertStringNotContainsString('hash-quelle', (string) $zip->getFromName(SitePackage::DATA), 'Ohne Häkchen keine Konten im Paket.');
|
||||
$zip->close();
|
||||
// Der Medienordner ist der des Pakets; der vorige Stand liegt in der Sicherung.
|
||||
self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
|
||||
self::assertNotEmpty(glob($this->sites['ziel']['root'].'/var/backups/sicherung-*'), 'Vor dem Import wird gesichert.');
|
||||
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance');
|
||||
}
|
||||
|
||||
public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void
|
||||
{
|
||||
// Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert.
|
||||
$ohne = $this->sites['quelle']['package']->export();
|
||||
$result = $this->sites['ziel']['package']->import($ohne, true);
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
|
||||
|
||||
// Paket mit Konten, aber ohne Häkchen beim Import: die Konten bleiben.
|
||||
$mit = $this->sites['quelle']['package']->export(true);
|
||||
self::assertTrue($this->sites['ziel']['package']->import($mit)['ok']);
|
||||
self::assertSame(['ziel'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT username FROM cms_user'));
|
||||
|
||||
// Mit Häkchen: die Konten des Pakets.
|
||||
self::assertTrue($this->sites['ziel']['package']->import($mit, true)['ok']);
|
||||
self::assertSame([['username' => 'quelle', 'password_hash' => 'hash-quelle']], $this->sites['ziel']['db']->fetchAllAssociative('SELECT username, password_hash FROM cms_user'));
|
||||
}
|
||||
|
||||
public function testAPackageFromANewerVersionIsRefused(): void
|
||||
{
|
||||
file_put_contents($this->sites['quelle']['root'].'/VERSION', "9.0.0\n");
|
||||
$result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export());
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('Erst diese Website aktualisieren', $result['steps'][0]['detail']);
|
||||
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{callable(\ZipArchive): void, string}> */
|
||||
public static function tamperedPackages(): iterable
|
||||
{
|
||||
yield 'fremde Datei' => [static fn (\ZipArchive $zip) => $zip->addFromString('../../public/hallo.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
|
||||
yield 'Skript als Medium' => [static fn (\ZipArchive $zip) => $zip->addFromString('medien/shell.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
|
||||
yield 'veränderter Inhalt' => [static function (\ZipArchive $zip): void {
|
||||
$zip->addFromString(SitePackage::DATA, str_replace('Seite der quelle', 'Untergeschoben', (string) $zip->getFromName(SitePackage::DATA)));
|
||||
}, 'Prüfsumme'];
|
||||
yield 'fremde Tabelle' => [static function (\ZipArchive $zip): void {
|
||||
$data = json_decode((string) $zip->getFromName(SitePackage::DATA), true);
|
||||
$data['tabellen']['form_submission'] = [];
|
||||
$raw = json_encode($data);
|
||||
$manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true);
|
||||
$manifest['sha256'] = hash('sha256', $raw);
|
||||
$zip->addFromString(SitePackage::DATA, $raw);
|
||||
$zip->addFromString(SitePackage::MANIFEST, json_encode($manifest));
|
||||
}, 'gehört nicht in ein Inhaltspaket'];
|
||||
yield 'ungültiges Dokument' => [static function (\ZipArchive $zip): void {
|
||||
$data = json_decode((string) $zip->getFromName(SitePackage::DATA), true);
|
||||
$data['tabellen']['revision'][0]['document'] = json_encode(['schemaVersion' => 1, 'title' => 'x', 'blocks' => [['id' => 'a', 'type' => 'unbekannt']]]);
|
||||
$raw = json_encode($data);
|
||||
$manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true);
|
||||
$manifest['sha256'] = hash('sha256', $raw);
|
||||
$zip->addFromString(SitePackage::DATA, $raw);
|
||||
$zip->addFromString(SitePackage::MANIFEST, json_encode($manifest));
|
||||
}, ''];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('tamperedPackages')]
|
||||
public function testATamperedPackageChangesNothing(callable $tamper, string $message): void
|
||||
{
|
||||
$file = $this->sites['quelle']['package']->export();
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($file);
|
||||
$tamper($zip);
|
||||
$zip->close();
|
||||
|
||||
$result = $this->sites['ziel']['package']->import($file);
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString($message, json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
|
||||
self::assertSame([str_repeat('b', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
|
||||
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/public/hallo.php');
|
||||
}
|
||||
|
||||
private static function remove(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($path) ?: [] as $entry) {
|
||||
if ('.' !== $entry && '..' !== $entry) {
|
||||
is_dir($path.'/'.$entry) ? self::remove($path.'/'.$entry) : @unlink($path.'/'.$entry);
|
||||
}
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ final class SystemScreenTest extends WebTestCase
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||
$this->previous = [$_SERVER, $_ENV];
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'MAIL_LOG' => $this->path.'-mail.log'] as $key => $value) {
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
@@ -81,6 +81,7 @@ final class SystemScreenTest extends WebTestCase
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
@unlink($this->path.'-mail.log');
|
||||
foreach (glob($this->path.'-media/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
@@ -359,6 +360,41 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
|
||||
}
|
||||
|
||||
public function testATestMailGoesToAnyAddressAndTheScreenSaysWhatHappened(): void
|
||||
{
|
||||
// Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt,
|
||||
// und wo Logs angezeigt werden, woran es liegt“ (ADR 121).
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('E-Mail-Versand', $html);
|
||||
self::assertStringContainsString('Noch nichts versendet', $html);
|
||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/testmail"] input[name=_csrf_token]')->attr('value');
|
||||
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'kein-postfach']);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString('keine gültige E-Mail-Adresse', (string) $client->getResponse()->getContent());
|
||||
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
|
||||
self::assertStringEndsWith('/admin/system#mail', (string) $client->getResponse()->headers->get('Location'));
|
||||
self::assertEmailCount(1);
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
|
||||
self::assertEmailAddressContains($mail, 'From', 'formular@cms.test');
|
||||
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen.', $html);
|
||||
self::assertStringContainsString('Testmail an <code>umut@cms.test</code>', $html, 'Der Versuch steht danach in „Letzte Versuche“.');
|
||||
|
||||
// Nur für Administratoren, und nur mit Token.
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => 'erfunden', 'to' => 'umut@cms.test']);
|
||||
self::assertEmailCount(0);
|
||||
$redaktion = $this->login('editor');
|
||||
$redaktion->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test']);
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
Reference in new issue
Block a user