Compare commits
No files matched your search
+27
-1
@@ -208,6 +208,32 @@
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8112
|
||||
},
|
||||
{
|
||||
"name": "ebi-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8106'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8106 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8106
|
||||
},
|
||||
{
|
||||
"name": "ebi-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8107'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8107 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8107
|
||||
},
|
||||
{
|
||||
"name": "zentrale",
|
||||
"runtimeExecutable": "C:/php84/php.exe",
|
||||
"runtimeArgs": ["-d", "curl.cainfo=C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt", "-S", "127.0.0.1:8115", "-t", "zentrale/public", "zentrale/public/index.php"],
|
||||
"port": 8115
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,6 @@ MAILER_DSN=sendmail://default
|
||||
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
|
||||
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.
|
||||
#ADMIN_PATH=admin
|
||||
# Die Zentrale, bei der sich diese Installation mit Adresse und Fassung meldet und von der sie
|
||||
# Updates annimmt. Ohne Eintrag: https://sites.kant-it.de. Abschalten mit ZENTRALE_URL=aus.
|
||||
#ZENTRALE_URL=https://sites.kant-it.de
|
||||
@@ -18,3 +18,8 @@
|
||||
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
|
||||
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
|
||||
/public/assets/build/
|
||||
|
||||
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
|
||||
/zentrale/config.php
|
||||
/zentrale/var/*
|
||||
!/zentrale/var/.htaccess
|
||||
@@ -0,0 +1,24 @@
|
||||
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
|
||||
#
|
||||
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
|
||||
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
|
||||
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
|
||||
# Sperrdatei.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -186,7 +186,9 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
|
||||
|
||||
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
|
||||
|
||||
**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. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
**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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -308,6 +310,8 @@ Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-
|
||||
|
||||
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
|
||||
|
||||
Die Aufgabe „Bei der Zentrale melden“ schickt alle 15 Minuten Adresse und Fassung an `https://sites.kant-it.de` und holt dort angestoßene Updates ab (ADR 123, `zentrale/README.md`). Abschalten mit `ZENTRALE_URL=aus` in `.env`; Installationen mit lokalem `SITE_ORIGIN` melden nie. Protokollprüfung: `php scripts/zentrale-check.php`.
|
||||
|
||||
Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
|
||||
@@ -397,7 +401,11 @@ 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]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
|
||||
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server 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.
|
||||
|
||||
### Redesign-Entwurf: EBI Immobilien (Karlsruhe)
|
||||
|
||||
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-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/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -2,3 +2,4 @@ framework:
|
||||
mailer:
|
||||
# Tests never send real mail; the transport collects messages in memory instead.
|
||||
dsn: 'null://null'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
|
||||
#
|
||||
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
|
||||
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
|
||||
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
|
||||
# Sperrdatei.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -10,6 +10,13 @@ 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'
|
||||
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
|
||||
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
|
||||
# Die Zentrale, bei der sich jede Installation meldet (ADR 123). ZENTRALE_URL=aus schaltet ab.
|
||||
kant.zentrale_default: 'https://sites.kant-it.de'
|
||||
kant.zentrale_url: '%env(default:kant.zentrale_default:ZENTRALE_URL)%'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
@@ -59,6 +66,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: '%kant.mail_dsn%'
|
||||
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
@@ -73,6 +86,10 @@ services:
|
||||
App\Command\HealthCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Controller\MailController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Controller\SystemController:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
@@ -90,6 +107,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,12 +143,31 @@ 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:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Zentrale:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$url: '%kant.zentrale_url%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$senden: null
|
||||
App\Ops\Tasks\ZentraleTask:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$anstossen: null
|
||||
App\EventSubscriber\RemoteUpdateFinishSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Tasks\CleanupTask:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -154,3 +198,12 @@ services:
|
||||
App\Twig\PluginExtension:
|
||||
arguments:
|
||||
$loader: '@twig.loader.native_filesystem'
|
||||
|
||||
# Tests: das Versandprotokoll gehoert nicht nach var/log der echten Installation, und die
|
||||
# Verwaltung soll einen festen SMTP-Weg als eingestellt sehen. Verschickt wird trotzdem ueber
|
||||
# null://null (config/packages/test/mailer.yaml), weil derselbe Weg den Transport des
|
||||
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
|
||||
when@test:
|
||||
parameters:
|
||||
kant.mail_log: '%kernel.cache_dir%/mail.log'
|
||||
kant.mail_dsn: 'smtp://cms.test:587'
|
||||
+120
-1
@@ -1,6 +1,125 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
|
||||
|
||||
Anlass: Umut: „Alle KantSites melden sich bei meiner Hauptplattform an … ich kann sehen, welche Domains welche Versionen nutzen, und kann von remote Aktualisierung anstoßen.“ Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle.
|
||||
|
||||
**Meldung (CMS).** Neue Aufgabe `zentrale` (`App\Ops\Tasks\ZentraleTask`, alle 15 Minuten) ruft `App\Ops\Zentrale::melden()` auf: `POST https://sites.kant-it.de/api/melden` mit Kennung, Adresse (`SITE_ORIGIN`), Fassung, PHP-Version, Datenbankart, Git/Archiv, Wartungszustand und Updatestand. Keine Inhalte, keine Besucherdaten, keine Zugangsdaten. Kennung und Schlüssel (32 bzw. 64 Hexzeichen, zufällig) liegen in `var/zentrale.json` und bleiben bei Updates erhalten. Abschalten mit `ZENTRALE_URL=aus`; Installationen mit lokalem `SITE_ORIGIN` (localhost, 127.0.0.1, `*.test`, `*.localhost`, `*.invalid`) melden nie.
|
||||
|
||||
**Echtheit ohne Lizenzschlüssel.** Beim ersten Kontakt schickt die Installation ihren Schlüssel über HTTPS mit (Vertrauen beim ersten Kontakt). Danach ist jede Meldung mit HMAC-SHA256 über den Rohtext unterschrieben (`X-KantSites-Signatur`), jede Antwort der Zentrale ebenso; die Installation verwirft Antworten ohne gültige Unterschrift. Eine bekannte Kennung mit fremdem Schlüssel weist die Zentrale ab (403). Kennt die Zentrale eine Kennung nicht mehr (409), meldet sich die Installation einmal mit Schlüssel neu an. Ein Auftrag kann nur „Update auf eine höhere Fassung der Form 1.2.3“ sein und läuft über `ArchiveUpdate` mit Prüfsumme der Releaseseite – mehr kann auch eine kompromittierte Zentrale nicht auslösen.
|
||||
|
||||
**Pull statt Push.** Die Zentrale verbindet sich nie mit einer Website. Sie merkt sich den Auftrag; die Website holt ihn mit ihrer nächsten Meldung ab. Keine Installation braucht dafür eine von außen erreichbare Steuerschnittstelle, und es geht auch hinter Firewalls.
|
||||
|
||||
**Update aus der Ferne.** `ZentraleTask` führt `ArchiveUpdate::run()` aus (Sicherung, Archiv, Prüfsumme, Dateitausch, Wartung an) und markiert den offenen zweiten Schritt mit `fern` (`ArchiveUpdate::markiereFern()`). Den zweiten Schritt erledigt die nächste Anfrage mit dem neuen Code: `RemoteUpdateFinishSubscriber` (kernel.request, Priorität 48, also vor der Wartungsseite) nimmt die Updatesperre, führt `finish()` mit der Funktionsprüfung aus und gibt die Website frei oder schaltet zurück. Damit das nicht auf den nächsten Besucher wartet, ruft die Aufgabe die eigene Startseite einmal auf (bis 300 s). Das Ergebnis geht mit der nächsten Meldung an die Zentrale und wird nach der Quittung aus `var/zentrale.json` gelöscht. Git-Arbeitskopien lehnen Aufträge ab. Die Funktionsprüfung nach den Migrationen ist dafür aus `SystemController` in `App\Ops\Funktionspruefung` gewandert; die Verwaltung nutzt denselben Dienst.
|
||||
|
||||
**Zentrale.** Eigener Ordner `zentrale/` im Repository, nicht Teil des Releasearchivs (`scripts/build-release.php` nimmt nur die aufgezählten Einträge). Reines PHP ab 8.1 mit SQLite (`var/zentrale.db`), ohne Framework und Composer, damit sie per FTP auf jedes Hosting passt. Anmeldung mit `password_hash`, Sitzungscookie `__Host-` mit SameSite=Strict, CSRF-Token auf jedem Formular, fünf Fehlversuche je IP in 15 Minuten, strenge CSP ohne Skripte, `noindex`. Das erste Konto legt an, wer den `einrichtungscode` aus `config.php` kennt. Die Übersicht zeigt Domain, Kunde, Fassung, letzte Meldung und Auftragsstand, zählt aktuelle, veraltete, verstummte (über 48 h) und nicht zugeordnete Installationen und bietet „Auf x.y.z“ je Zeile und „Alle aktualisieren“ mit Bestätigung (ohne verstummte, wartende und Git-Installationen). Die neueste Fassung kommt von derselben Releaseseite wie bei den Installationen, 30 Minuten zwischengespeichert. Fremde Nutzung erkennt man an „Unbekannt“ (kein Kunde zugeordnet) und am Hinweis „vorher …“ bei einer Kennung, die sich unter neuer Domain meldet.
|
||||
|
||||
**Grenzen.** Wer den Code verändert und die Meldung ausbaut, bleibt unsichtbar; ohne Lizenzschlüssel ist das kein Schutz, nur eine Übersicht. Installationen vor 1.36.0 erscheinen erst nach einem Update von Hand. Erreicht eine Website sich selbst nicht, steht die Wartungsseite bis zum nächsten Besuch oder Cron-Aufruf. Die Zentrale speichert die IP-Adresse der letzten Meldung; das gehört in die Datenschutzerklärung beziehungsweise Auftragsverarbeitung mit den Kunden.
|
||||
|
||||
**Folgen.** Keine Migration im CMS: `var/zentrale.json` entsteht beim ersten Lauf, die Aufgabe trägt sich über ihr Etikett selbst in `scheduled_task` ein. Das Schema der Zentrale migriert sich über `PRAGMA user_version` (`zentrale/app/Datenbank.php`). Protokollprüfung ohne Netz: `php scripts/zentrale-check.php`; der Ablauf mit Dateitausch und Abschluss steht in `tests/ArchiveUpdateTest.php`.
|
||||
|
||||
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
|
||||
|
||||
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass `sendmail -bs` dort abbricht und `native://default` (PHP `mail()`) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
|
||||
|
||||
**E-Mail-Versand.** Eigene Seite `/admin/system/e-mail` (`MailController`), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP `mail()`, `sendmail -t`, `sendmail -bs`. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt `MAILER_DSN` über `Environment::write` in `.env` (nur wenn sie dort steht, nicht aus der Server-Umgebung). `MailCheck::smtpDsn` baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird `smtps`. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit `Transport::fromDsn` gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst `mail()`.
|
||||
|
||||
**Probe.** `OriginCheck::PATH` ist jetzt `/kantsites-origin` statt `/.well-known/kantsites-origin`. IONOS beantwortet alles unter `/.well-known/` selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für `.well-known` in `public/.htaccess` bleibt für Zertifikatsanbieter.
|
||||
|
||||
**Folgen.** Die Tests setzen die angezeigte Einstellung über `when@test` in `config/services.yaml` (`kant.mail_dsn`, `kant.mail_log`); in `config/packages/test/` wurden die Parameter von `services.yaml` überschrieben, so dass die Tests von 1.34.0 in das lokale `var/log/mail.log` schrieben (nicht Teil eines Releases). Keine Migration.
|
||||
|
||||
## 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.
|
||||
|
||||
**Entscheidungen.**
|
||||
1. **Klick daneben schließt – ohne Skript.** Das Menü bleibt ein `details` (es soll auf jeder Seite ohne Skript aufgehen). Solange es offen ist, legt `summary::before` eine unsichtbare, feste Fläche über die Seite (`z-index:29`); sie gehört zum Knopf, ein Klick darauf schließt also. Die Menüfläche liegt darüber (`z-index:30`; außerhalb des Kopfbereichs per `:where()` mit niedriger Spezifität, damit die Platzierung im Kopf gewinnt). Der erste Klick daneben schließt nur und löst nichts darunter aus – wie bei jedem aufgeklappten Menü. Escape schließt weiterhin nicht (das bräuchte ein Skript). Grenze: Hat ein Vorfahr des Menüs `transform`/`filter`, deckt die feste Fläche nur diesen Vorfahren ab.
|
||||
2. **Facebook, Instagram, WhatsApp im Symbolsatz.** Dieselben Strichformen wie `social-icon.html.twig`, als Pfad in `icon.html.twig`, `icons.js` (Reiter „Soziale Netzwerke“) und `DocumentValidator::ICONS`. Damit sind sie an Menü-Buttons, Buttons, Symbolkarten und Symbolen wählbar. Keine Schemaänderung; ein Dokument mit diesen Symbolen braucht aber einen Kern ab 1.31.0.
|
||||
3. **Strichattribute ins Stylesheet.** Jedes Symbol trug `fill`, `stroke`, `stroke-width`, `stroke-linecap` und `stroke-linejoin` als Attribute, rund 105 Zeichen, bei etwa 40 Symbolen je Seite gut 4 KB HTML. Jetzt steht das einmal als `svg.block-icon{…}` in `site.css` und `admin.css`; `width`/`height` bleiben am Element, damit nichts ohne Stylesheet riesig wird. Anlass war die Hüfner-Startseite, die mit den Menü-Buttons das HTML-Budget von 150 KB riss.
|
||||
|
||||
**Geprüft:** Eigene Browserprüfung auf iPad (quer) und iPhone: Menü öffnet, Menülinks liegen über der Fläche, Klick daneben schließt, kein waagerechtes Scrollen. `test:menu-buttons` (Menü-Buttons, axe), `test:editor` (Verwaltung mit Symbolen, axe), `npm test` (Symbolsatz in Twig, JS und Validator gleich). `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – ebenso ohne diese Änderungen (Gegenprobe mit zurückgesetzten Dateien), also vorbestehend.
|
||||
|
||||
## ADR 116 – Härtung nach dem Sicherheitscheck (12.10.2026)
|
||||
|
||||
Anlass: Umut: „Kannst du mal einen Sicherheitscheck für KantSites machen? Das Ding muss bombensicher sein.“ Fünf getrennte Prüfungen (Anmeldung/Rollen, öffentliche Eingaben, XSS/Ausgabe, Uploads/Dateien, Installer/Updater/Betrieb) plus ein Blick von außen auf die Live-Seiten. Keine ausnutzbare Lücke ohne Anmeldung bei korrekt eingerichteter Installation; behoben wird, was übrig blieb.
|
||||
|
||||
**Entscheidungen.**
|
||||
1. **Installationsschlüssel.** Der Installer war bis zum ersten Konto für jeden offen – wer eine frisch entpackte Installation zuerst aufrief, konnte sie übernehmen (und vorher über den MySQL-Host fremde Rechner abtasten). Jetzt öffnet sich kein Schritt ohne einen 128-Bit-Schlüssel in `var/install.key`. `install.php` legt ihn an und zeigt ihn nur auf seiner Abschlussseite mit fertigem Verweis (`/install/schluessel?schluessel=…`); wer per FTP entpackt, liest die Datei. Im Browser bleibt ein Cookie `kant_install` mit einem HMAC über den Schlüssel (HttpOnly, SameSite=Strict, Pfad `/install`), nie der Schlüssel selbst. Mit dem ersten Konto wird die Datei gelöscht; das Anlegen des ersten Kontos läuft in einer Transaktion. Restrisiko: Wer `install.php` vor dem Betreiber aufruft, sieht den Schlüssel – das Fenster ist die Zeit zwischen Hochladen und Aufrufen.
|
||||
2. **Sperrdateien.** Zeigte die Domain auf den Projektordner statt auf `public/`, waren `.env`, Datenbank, Sicherungen und Protokolle abrufbar; die Sperren standen nur in `public/.htaccess`. Jetzt sperren `.htaccess`-Dateien `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/` (Buildskript) und `var/` (legt die Website selbst an, `App\Ops\Protection`), dazu im Projektstamm gezielt Dotfiles, `env|db|sqlite|lock|md|yaml|yml|dist|log|key` und `composer.json`, `VERSION`, `cli-config.php`, `migrations.php`. Bewusst **kein** pauschales „alles sperren“ im Projektstamm: Apache wendet die Datei auch auf `public/` an, ein Update hätte jede Seite mit 403 lahmlegen können. Eine eigene `.htaccess` des Betreibers im Projektstamm bleibt unberührt; fehlende Dateien trägt die Website beim ersten Aufruf nach (auch nach einem Update von einer Fassung, deren Updater sie nicht kannte). Vorlagen: `config/schutz/`.
|
||||
3. **Sektionen nach ihrem eigenen Schema-Stand.** Eine verknüpfte Sektion wurde mit den Ausgabeflags der Seite gerendert. Eine Sektion mit Schema unter v12 (deren Text nicht gefiltert wird) gab so in einer neueren Seite ungefiltertes HTML aus – ein Publisher konnte `<meta refresh>` oder fremde Links setzen, was sonst nur der Admin darf. Jetzt rechnet die Vorlage mit `section.schemaVersion`; neue Sektionen beginnen im aktuellen Schema.
|
||||
4. **Redakteure ändern nichts Veröffentlichtes.** Adresse verschieben, Papierkorb (jeweils samt Unterseiten), Kopf/Fuß wählen und Beitragsdaten (robots, SEO-Titel, Datum …) wirkten sofort öffentlich und brauchten nur ROLE_EDITOR. Jetzt verlangen sie ROLE_PUBLISHER, sobald die Seite (bzw. eine Unterseite) veröffentlicht ist; Entwürfe bleiben frei.
|
||||
5. **Geplante Veröffentlichung mit fester Fassung.** Zum Termin ging der neueste Entwurf online, auch einer, den ein Redakteur nach dem Planen gespeichert hatte. Jetzt speichert `schedule()` die damalige Fassung in `page.publish_revision_id` (Migration `20261012000100`, eine Spalte); `publishDue()` veröffentlicht genau sie. Planungen ohne Fassung (aus älteren Versionen) veröffentlichen weiterhin den Entwurf.
|
||||
6. **Formulare.** Das Zeitsiegel ist an das Formular und den Zweck gebunden (`FormRepository::stamp`). Die Grenze je Absender zählt bei IPv6 das /64-Netz. Neu: höchstens 30 gespeicherte Anfragen je Formular und 120 insgesamt je Stunde; darüber wird nichts gespeichert und nichts verschickt – sichtbar mit Hinweis aufs Telefon statt still, damit kein echter Besucher glaubt, seine Anfrage sei angekommen.
|
||||
7. **Kleinere Härtungen.** Einwilligungs-Rücksprung ohne Steuerzeichen und Backslash (Tabulator machte aus `/<Tab>/fremd` eine offene Weiterleitung). Update-Ziel nur in der Form `1.2.3` bzw. `v1.2.3`, `git checkout` mit `--`. Eigenes CSS: keine Geschwister des Blocks selbst (`& ~`, `& +`), Anführungszeichen paarweise, keine Kommentarzeichen in Werten; bereits gespeichertes CSS, das das verletzt, entfällt beim Ausgeben still, statt die Seite zu brechen.
|
||||
|
||||
**Nicht umgesetzt, mit Grund.** Signierte Releases (Umut: später); dann gehört ein Offline-Schlüssel dazu. Antwortzeit beim Login für unbekannte Benutzer, Trusted Proxies, Turnstile-Hostname, `%PDF-` ab Byte 0, archivierte PDFs, Statistik-Obergrenzen, MySQL-Passwort in der Prozessliste: niedrig, offen in STATUS.
|
||||
|
||||
**Folgen.** Neuinstallationen brauchen den Schlüssel; die Doku sagt, wo er steht. Bestehende Installationen merken nur die Sperrdateien (keine Wirkung bei korrekter Einrichtung) und die engeren Rollen. Migration läuft mit dem Updater. Ausgeliefert mit 1.30.0.
|
||||
|
||||
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
|
||||
|
||||
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
|
||||
|
||||
**Entscheidung.** Das Formular bleibt der Weg, ein kleines Skript `public/assets/consent.js` verbessert ihn. Es schickt dasselbe Formular im Hintergrund an `/einwilligung` mit der Kopfzeile `X-Kant-Consent: 1`; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an `#main`. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein `data-src` im Platzhalter.
|
||||
|
||||
**Was sich dafür ändert.** (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in `frame-src`, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden `script-src 'self'` und laden `consent.js`; nach der Entscheidung ohne wartende Inhalte wieder `script-src 'none'`. (3) Das Skriptbudget von `test:browser` (3 je Seite) zählt `consent.js` nicht mit, weil es nur bis zur Entscheidung da ist.
|
||||
|
||||
**Folgen.** Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als `PageController` (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
|
||||
|
||||
## ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+146
@@ -1,5 +1,137 @@
|
||||
# Projektstatus
|
||||
|
||||
## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch.
|
||||
|
||||
**Erledigt:** CMS: Aufgabe „Bei der Zentrale melden“ (`zentrale`, 15 min) mit `App\Ops\Zentrale` (HMAC-signierte Meldung, Schlüssel beim ersten Kontakt, Antwort wird geprüft), Auftrag „Update“ über `ArchiveUpdate`, Abschluss durch die nächste Anfrage (`RemoteUpdateFinishSubscriber`), Funktionsprüfung als Dienst `Funktionspruefung`. `ZENTRALE_URL` (Standard `https://sites.kant-it.de`, `aus` schaltet ab), lokale Adressen melden nie. Plattform in `zentrale/` (PHP 8.1+, SQLite, ohne Composer): Einrichten mit Code aus `config.php`, Anmeldung mit Sperre nach Fehlversuchen, Übersicht mit Zählern, „Unbekannt“ für Installationen ohne Kunde, Domainwechsel sichtbar, Update je Zeile und „Alle aktualisieren“ mit Bestätigung, Detailseite mit Stand, Kunde/Notiz, Auftragsverlauf, Zurückziehen und Entfernen. Anleitung: `zentrale/README.md`. ADR 123.
|
||||
|
||||
**Geprüft:** PHPUnit `ArchiveUpdateTest` (16 Tests, darunter der ganze Fernablauf mit Dateitausch, Abschluss durch die Folgeanfrage und Ergebnis an die Zentrale; gefälschte Antwort; lokale Installationen melden nicht), `TaskScreenTest`, `SchedulerTest`, `SystemScreenTest` grün. `php scripts/zentrale-check.php`: 14/14 (CMS-Klasse gegen echte API: Anmeldung, Auftrag abholen, nicht doppelt, Ergebnis, 403 bei falscher Unterschrift und fremdem Schlüssel, Neuanmeldung, Domainwechsel). Plattform lokal im Browser (Port 8115, Demodaten): Einrichten, Anmelden, Übersicht mit echter neuester Fassung 1.35.1 von git.kant-it.de, Einzelauftrag, Zurückziehen, „Alle aktualisieren“, Detailseite, 375 px ohne Querscrollen, keine Konsolenfehler, CSP und Cookie-Flags. Nicht geprüft: echter Lauf über das Internet gegen sites.kant-it.de (gibt es noch nicht), Selbstaufruf auf einem echten Hosting, Lighthouse der Plattform (interne Seite, `noindex`). Volle PHPUnit-Kette: 685 Tests grün (ein Fund unterwegs: `zentrale` musste in die Ausschlussliste von `build-release.php`), php-cs-fixer ohne Befund.
|
||||
|
||||
**Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git.
|
||||
|
||||
**Nächster Schritt:** Release 1.36.0 bauen und veröffentlichen, `zentrale/` nach sites.kant-it.de hochladen (Docroot `public/`, HTTPS, `config.php`), erstes Konto anlegen, kant-it.de auf 1.36.0 heben und die erste Meldung sowie ein Fern-Update mit 1.36.1 prüfen.
|
||||
|
||||
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
|
||||
|
||||
**Anlass:** Umut: „einfacher Rebuild und Redesign von ebi-immobilien.de, soll vollendet aussehen, aber nicht übertrieben clean“.
|
||||
|
||||
**Erledigt:** Neue Installation `var/ebi-immobilien.db` (Port 8106, Messserver 8107), Aufbau nur per `sh scripts/reset-ebi-immobilien.sh` (Bilder: `build-ebi-immobilien-assets.php`, Inhalte: `seed-ebi-immobilien.php`). Logo, Fotos, Kontaktdaten und Formularfelder von der alten WordPress-Seite; Gestaltung neu: Navy und Logo-Blau, Sand als warme Fläche, Lora/Manrope vom eigenen Server. Startseite (Hero mit Umuts KI-Startbild, Leistungskarten, Suchauftrag, Bewertung, Kontaktband), Formularseiten Suchauftrag (2 Schritte), Immobilienbewertung (3 Schritte, bei Grundstück ohne Zimmer/Baujahr/Küche) und Kontakt, Impressum, Datenschutz, 404, 301 von `/contact-us`, `/immobilien`, `/immobilienbewertungen`, `/property-search`. „Jetzt anrufen“ als Menü-Button, auf dem Telefon im Klappmenü. Kein Kerncode geändert.
|
||||
|
||||
**Geprüft:** Im Browser bei 1440 und 375 px (Startseite, Formularseiten, Klappmenü, bedingte Felder bei „Grundstück“, kein waagrechtes Scrollen). `test:browser` gegen 8106 grün (Barrierefreiheit, Tastatur, ohne JS, Budget: HTML 59 KB). Lighthouse (`audit.mjs`, Median aus 3 Läufen) auf 8107 für `/`, `/suchauftrag`, `/immobilienbewertung`, `/kontakt`: überall 100 in Barrierefreiheit, Best Practices, SEO und Agentic Browsing; Leistung 100, nur `/immobilienbewertung` mobil 99. Ein Formular wurde nicht abgeschickt, der Mailversand ist nicht geprüft. PHPUnit lief nicht (kein Kerncode geändert).
|
||||
|
||||
**Bekannte Lücken:** Eine Objektliste gibt es nicht (das Original zeigte „0 Results“). Im Impressum fehlen Inhaber, Rechtsform und Angaben nach § 34c GewO – die alte Seite nennt sie nicht. Das Startbild hat nur 1672 px Breite.
|
||||
|
||||
**Nächster Schritt:** Impressumsangaben von Umut, dann Hoster/Livegang klären (Inhaltspaket `var/ebi-immobilien-paket.zip` liegt bereit).
|
||||
|
||||
## Fassung 1.35.1: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
|
||||
|
||||
**Anlass:** Umut: E-Mail-Seite und „Inhalte übertragen“ sehen uneinheitlich aus, Unterpunkt „E-Mail-Versand“ steht immer offen, „Logs leeren“ fehlt.
|
||||
|
||||
**Erledigt:** Fließtext in Karten hat jetzt durchgehend 13 px (vorher Browser-16 px neben 13 px), Felder bleiben 16 px. Neue gemeinsame Bausteine in `admin.css`: `.card-section` (Abschnitt mit Trennlinie), `.button-row`, `.form-foot`, `.option`/`.option-list` (Auswahl als Karten), `.pill-error`, Dateiauswahl im Knopfstil. E-Mail-Seite: Wege als Karten mit Erklärung, SMTP 2×2, Testmail-Abschnitt, „Speichern“ als Hauptknopf im Kartenfuß (war ungestylt). Protokoll mit Zustandsplakette, Hinweis vorn, Servermeldung zugeklappt (`admin/_mail_entry.html.twig`). „Protokoll leeren“ (`POST /admin/system/e-mail/protokoll`, löscht `mail.log` und `mail.log.1`). Unterpunkt nur im Bereich System sichtbar; in der waagrechten Leiste (761–1000 px) neben „System“.
|
||||
|
||||
**Geprüft:** `SystemScreenTest` erweitert (Leeren nur mit Token, danach leer, Editor 403, Unterpunkt auf Einstellungen nicht sichtbar). PHPUnit vollständig 682 grün, php-cs-fixer sauber, `lint:twig`, `test:admin`, `test:editor` grün. Im Browser (Hüfner lokal) bei 1240, 900 und 375 px: E-Mail-Seite, Systemseite, Einstellungen; kein waagrechtes Scrollen am Handy.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.35.1`, Release-Eintrag mit Archiv (3635 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Hüfner auf 1.35.1 aktualisieren (die offenen Punkte von 1.35.0 gelten weiter).
|
||||
|
||||
## Fassung 1.35.0: E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
|
||||
|
||||
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
|
||||
|
||||
**Erledigt (ADR 122):** Seite `/admin/system/e-mail` unter „System“: Weg wählen (SMTP, mail(), sendmail -t, sendmail -bs), testen ohne Speichern, alle Wege nacheinander testen, speichern nach `.env`; Passwort nie im Formular. Karte von der Systemseite entfernt. Probe der Adresse von `/.well-known/kantsites-origin` nach `/kantsites-origin`, weil IONOS `/.well-known/` selbst mit 403 beantwortet. Test-Parameter über `when@test`.
|
||||
|
||||
**Geprüft:** `MailCheckTest` (DSN aus Feldern samt Sonderzeichen, Vorbelegung ohne Passwort, Wege ausprobieren über die Fabrik, Hinweis bei fremdem Absender), `SystemScreenTest` (Menüpunkt, Seite, Testen, fehlendes Passwort, Speichern nur aus `.env`, nur Administratoren), `HttpsRedirectTest`. PHPUnit vollständig 682 grün (die bekannte Warnung aus `UpdaterTest`), php-cs-fixer sauber; `var/log/mail.log` entsteht durch Tests nicht mehr. Im Browser auf der Wegwerfkopie: „Alle Wege testen“ zeigt je Weg Ergebnis und Hinweis, SMTP-Felder verschwinden bei anderen Wegen, Handybreite ohne waagrechtes Scrollen, Unterpunkt im Menü markiert. Live auf IONOS ist die neue Probe noch nicht geprüft; dass `/.well-known/` dort 403 liefert und `/index.php/...` durchkommt, ist nachgemessen.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.35.0`, Release-Eintrag mit Archiv (3634 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Hüfner auf 1.35.0 aktualisieren, „HTTPS erzwingen“ erneut drücken, SMTP über das zentrale kant-it.de-Postfach einrichten und testen.
|
||||
|
||||
## 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ü.
|
||||
|
||||
**Neu im Kern (ADR 117):** Klick neben das aufgeklappte Menü schließt es – reines CSS (unsichtbare Fläche am Menü-Knopf), kein Skript. Facebook, Instagram und WhatsApp im Symbolsatz (Menü-Buttons, Buttons, Symbole). Die Strichattribute der Symbole stehen jetzt als `svg.block-icon` in `site.css`/`admin.css` statt an jedem Symbol (rund 4 KB weniger HTML je Seite).
|
||||
|
||||
**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. **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)
|
||||
|
||||
**Anlass:** Umut: „Sicherheitscheck für KantSites … das Ding muss bombensicher sein.“ Prüfung in fünf Bereichen plus Blick von außen auf huefner-gartenbau.de und kant-it.de; Ergebnis im Chat vom 12.10.2026, Umsetzung „1.30.0, Signatur später“.
|
||||
|
||||
**Behoben (ADR 116):** Installationsschlüssel für den Installer (`var/install.key`, von `install.php` angelegt und angezeigt); Sperrdateien im Projektstamm und in allen internen Ordnern (`var/` legt die Website selbst an); verknüpfte Sektionen nach ihrem eigenen Schema-Stand gerendert (kein ungefiltertes HTML durch alte Sektionen); Redakteure ändern Adresse, Papierkorb, Bereiche und Beitragsdaten veröffentlichter Seiten nicht mehr; geplante Veröffentlichung mit fester Fassung (Migration `20261012000100`); Formular-Siegel je Formular, IPv6 je /64, Obergrenze je Formular und gesamt mit sichtbarem Hinweis; offene Weiterleitung per Tabulator beim Einwilligen; Update-Ziel nur `1.2.3`; eigenes CSS bleibt im Block.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 657 Tests: im Gesamtlauf 656 grün, ein Fehlschlag (`ArchiveUpdateTest`: die neue `.htaccess` im Projektstamm stand weder in der Ersetzungsliste noch in den bewussten Ausnahmen – jetzt als Ausnahme eingetragen, weil sie dem Betreiber gehören kann), danach die betroffenen Dateien (ArchiveUpdate, Cms, Installation, InstallFlow, SingleFileInstaller, Translation; 436 Tests) erneut grün. Neu getestet: Installer nur mit Schlüssel (falscher Schlüssel, gefälschtes Cookie, Verweis aus install.php, Löschen nach dem ersten Konto), Sperrdateien, Sektion mit altem Schema, Redakteur vs. veröffentlichte Seite, geplante Fassung, Formular-Siegel je Formular, IPv6-/64, Flut-Grenze mit Hinweis, Einwilligungs-Rücksprung mit Tabulator/Umbruch/Backslash, CSS-Geschwister/Kommentare. Gegenprobe: der Sektionstest schlägt ohne den Fix an. `npm test` 41, `lint:twig`, php-cs-fixer; `test:browser` auf Hüfner grün; `test:editor` grün inklusive axe; im Browser ein echtes Absenden des Hüfner-Formulars mit neuem Siegel (Testeinsendung danach gelöscht). Hüfner-Upload-Paket mit migrierter Datenbank (`20261012000100`) neu gebaut.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.30.0`, Release-Eintrag mit Archiv (5,5 MB, gebaut aus `var/release-source-1.30.0`, enthält alle Sperrdateien inkl. `vendor/.htaccess`), `.sha256` und `install.php` (mit Installationsschlüssel); Prüfsumme nach dem Herunterladen bestätigt. Eine Migration (`20261012000100`), läuft mit dem Updater.
|
||||
|
||||
**Nächster Schritt:** Installationen auf 1.30.0 aktualisieren (huefner-gartenbau.de, kant-it.de, weitere); bei Hüfner danach das Upload-Paket einspielen und die HTTPS-Zeilen einschalten.
|
||||
|
||||
**Offen (niedrig):** signierte Releases; Login-Antwortzeit für unbekannte Benutzer; Trusted-Proxy-Einstellung für Hosting hinter CDN; Turnstile-Hostname; `%PDF-` ab Byte 0; archivierte PDFs bleiben abrufbar; Statistik ohne Obergrenze für erfundene Verweise; MySQL-Passwort in der Prozessliste bei Sicherungen; Theme-Vorlagen ohne Versionsprüfung. **Live:** huefner-gartenbau.de antwortet auch über `http://` ohne Umleitung und ohne HSTS – die HTTPS-Zeilen in `public/.htaccess` einschalten.
|
||||
|
||||
## Fassung 1.29.0: Einwilligung ohne Neuladen (12.10.2026)
|
||||
|
||||
**Anlass:** Umut auf der Hüfner-Seite: Beim Einwilligen ins YouTube-Video lädt die Seite komplett neu.
|
||||
|
||||
**Neu im Kern (ADR 115):** `public/assets/consent.js` schickt Hinweis und Knopf am eingebetteten Inhalt im Hintergrund ab (`X-Kant-Consent: 1` → 204 mit Cookie statt Weiterleitung), holt die Seite neu und tauscht nur Einbettungen und „Rechtliches“ aus; Fokus an den Rahmen bzw. an den Seitenanfang. Ohne JavaScript und bei Fehlern bleibt das Formular mit Neuladen. `frame-src` nennt die Hosts aller Einbettungen der Seite auch vor der Einwilligung (die Adresse des Inhalts weiterhin erst danach im HTML). Das Skript lädt nur, solange eine Einwilligung offen ist; `test:browser` zählt es nicht zum Budget von drei Skripten.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 644 grün (neu: 204-Antwort mit Cookie, kein Cookie von fremder Herkunft, Weiterleitung ohne Kopfzeile; angepasst: Skript bei offener Frage, `script-src 'none'` nach der Entscheidung, `frame-src` vor der Einwilligung), `npm test` 41, `lint:twig`, php-cs-fixer. Im Browser auf der Hüfner-Kopie: Knopf am Video, „Nur notwendige“ und „Alle akzeptieren“ ohne Neuladen, Fokus aufs Video, ohne JavaScript weiterhin mit Neuladen, keine Konsolenfehler. `test:browser` grün, Lighthouse Produktion Startseite mobil 96, Desktop 98, sonst 100. `test:editor` nicht gelaufen (Editor unverändert, Skript dort nicht eingebunden).
|
||||
|
||||
**Veröffentlicht:** Tag `v1.29.0`, Release-Eintrag mit Archiv (3611 Dateien, 5,3 MB, gebaut aus `var/release-source-1.29.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Nächster Schritt:** Installationen mit Einwilligungshinweis (huefner-gartenbau.de, Immobilien DEEG) auf 1.29.0 aktualisieren.
|
||||
|
||||
## Fassung 1.28.1: weißer 500er bei IONOS durch die Umschreibregel (12.10.2026)
|
||||
|
||||
**Anlass:** huefner-gartenbau.de bei IONOS zeigte nach der Einrichtung auf jeder Adresse einen weißen 500er.
|
||||
@@ -8,10 +140,24 @@
|
||||
|
||||
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.28.1`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
|
||||
|
||||
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
|
||||
|
||||
## Hüfner: Änderungen aus dem Kundentermin (12.10.2026)
|
||||
|
||||
**Anlass:** Umuts handschriftliche Notizen vom Hüfner-Termin plus Antworten auf Rückfragen. Nur Inhalt und Seed-Skript, kein Kerncode.
|
||||
|
||||
**Erledigt (`scripts/seed-huefner.php`, `build-huefner-assets.php`, `reset-huefner.sh`):** Alle Grüntöne auf das Baumgrün des Logos (#067F30) gedreht, „Premium“ und Hervorhebungen in Baumgrün, kleine Schrift auf Dunkel weiß statt hellgrün (Kontrast). Das „💯“ als Umuts Emoji-Bild überall, wo „100“ steht, im Text (`<mark>100</mark>`: die Zahl bleibt lesbar für Vorleser und KI, sichtbar ist das Bild in Schriftgröße, geschütztes Leerzeichen vor „%“); Quelle `100-emoji.png` in den Unterlagen. „Miteinander. 💯 %.“-Abzeichen jetzt dunkel statt rot. Freistellungsbescheinigungen (§ 13b UStG, § 48b EStG) als PDF bei „Planung & Angebot“ und im Fuß, PQ-Bescheinigung unter „Über uns“ und im Fuß, Galabau-Signet und PQ-Logo. Imagevideo (YouTube, youtube-nocookie) als „Externer Inhalt“ – dafür jetzt Einwilligungshinweis (Ecke) und angepasste Datenschutzerklärung. Google-Bewertungen unter „100 % Qualität. 100 % Zufriedenheit.“ mit 4,9 / über 150 als Beleg, drei echte Rezensionen (gekürzt, Namen abgekürzt), Knopf zum Eintrag. 48-h-Angebotsgarantie in Ablauf und Kontakt, „Leistung“ aus dem Formular, Chips verlinken zum Formular, WhatsApp (0176 30387990) in Kontakt und Karriere, 25 Jahre / 7.000+ Projekte, „Bagger bis 15 t“ raus. Karriere: Facharbeiter und LKW-Fahrer C/CE (wie Instagram) als zwei gleich hohe, aufklappbare Karten (details/summary, SVG-Zeichnungen), „Ein Anruf genügt!“. Neue Seite `/alpakas` mit Umuts Text, Ergänzungen, Galerie und Plakat; „Alpakas“ im Menü, „Ablauf“ aus dem Menü raus. `browser-check.mjs`: PDFs zählen beim „erster interner Link“-Klick nicht mit.
|
||||
|
||||
**Geprüft:** `test:browser` auf 8102 für `/`, `/alpakas`, `/datenschutz`, `/impressum` grün (HTML Startseite 148.905 B von 150.000 – knapp). Lighthouse Produktion (`reports/lighthouse-huefner-termin`): `/` mobil 96, Desktop 99; `/alpakas` mobil 95, Desktop 100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing überall 100. Im Browser: Video lädt erst nach Einwilligung, Stellenkarten klappen auf, Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Bekannt / offen:** Die Freistellungsbescheinigung § 13b gilt nur bis 31.12.2026 – dann neue Datei in die Unterlagen und neu aufbauen. Die Alpaka-Videos (mp4) sind nicht eingebunden (keine Videoablage im Kern). Offen von Umut: Logos der Sponsoren, „Über uns – mehr lesen“, ggf. Stellenbilder aus ChatGPT statt der SVG-Zeichnungen, genauer Verbandsname. Die Sprachtest-Kopie (`var/browser-tests/lang`) teilt `var/huefner-media` und hat nach dem Neuaufbau keine passenden Bilder mehr. Sicherung vorher: `var/backups/huefner-vor-termin-1210/`. Nebenbefund Kern: `CustomCode::parse` lehnt `url('…')` mit Anführungszeichen immer ab (optionales Anführungszeichen im regulären Ausdruck backtrackt).
|
||||
|
||||
**Nächster Schritt:** Umuts Durchsicht; fehlende Punkte nachliefern lassen.
|
||||
|
||||
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
|
||||
|
||||
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
|
||||
|
||||
@@ -97,6 +97,10 @@ Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nich
|
||||
|
||||
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
|
||||
|
||||
## Der vierte Weg: aus der Zentrale
|
||||
|
||||
Ab 1.36.0 meldet sich jede Installation bei `sites.kant-it.de` und holt dort angestoßene Updates ab. Es läuft derselbe Archivweg wie über den Knopf: Sicherung, Archiv mit Prüfsumme, Dateitausch, Migrationen mit dem neuen Code, Prüfung der Startseite, sonst Zurückschalten. Den zweiten Schritt erledigt die nächste Anfrage an die Website; die Aufgabe ruft dafür die eigene Startseite einmal auf. Git-Arbeitskopien lehnen Aufträge ab. Einzelheiten: ADR 123, `zentrale/README.md`.
|
||||
|
||||
## Ein Release veröffentlichen
|
||||
|
||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261012000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Geplante Veröffentlichung: welche Fassung zum Termin online geht (ADR 116).';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Bisher ging zum Termin der jeweils neueste Entwurf online - auch einer, den nach dem
|
||||
// Planen jemand ohne Recht zum Veröffentlichen gespeichert hatte. Jetzt merkt sich die
|
||||
// Planung die Fassung, die beim Planen vorlag. Eine Spalte, kein Tabellenneubau.
|
||||
$schema->getTable('page')->addColumn('publish_revision_id', 'string', ['length' => 32, 'notnull' => false, 'default' => null]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('page')->dropColumn('publish_revision_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
+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: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
|
||||
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
|
||||
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
|
||||
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
|
||||
<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.
|
||||
|
||||
+79
-1
@@ -1,3 +1,5 @@
|
||||
/* Der Strich aller Symbole aus icon.html.twig, wie auf der Website (site.css). */
|
||||
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
||||
/* Administration: shared foundation and the management screens.
|
||||
The editor has its own stylesheet (editor.css) that only the editor and the media page load. */
|
||||
|
||||
@@ -291,7 +293,24 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0; font-size: 13px; line-height: 1.6 }
|
||||
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
|
||||
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
|
||||
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
|
||||
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
|
||||
.card-body h3:first-child { margin-top: 14px }
|
||||
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
|
||||
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
|
||||
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
|
||||
.card-section > legend + * { clear: both }
|
||||
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
|
||||
.card-section .check-label { margin: 14px 0 0 }
|
||||
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
|
||||
.card-section label.field-first { margin-top: 0 }
|
||||
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
|
||||
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
|
||||
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
input[type=file]:hover::file-selector-button { background: #e8ede6 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
@@ -304,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.card-body > p:first-child { margin-top: 14px }
|
||||
.card-body .primary, .card-body .secondary { margin-top: 22px }
|
||||
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
|
||||
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
|
||||
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
|
||||
.form-foot .primary { margin: 0 }
|
||||
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
|
||||
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
|
||||
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
|
||||
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
|
||||
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
|
||||
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
|
||||
.option:hover { border-color: #a9b8af }
|
||||
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
|
||||
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
|
||||
.option > span { display: grid; gap: 2px; min-width: 0 }
|
||||
.option strong { font-size: 13px; font-weight: 600 }
|
||||
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
|
||||
.check-label input, .option input { accent-color: var(--accent) }
|
||||
|
||||
/* A card that opens: used for everything secondary, so a screen starts calm. */
|
||||
details.card > summary {
|
||||
@@ -374,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.pill-live { background: #e7f0d6; color: #2c5340 }
|
||||
.pill-draft { background: #fff2d9; color: #6f4e18 }
|
||||
.pill-off { background: #f1f2f0; color: #5a635e }
|
||||
.pill-error { background: #fff0eb; color: var(--danger) }
|
||||
|
||||
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
|
||||
that clip each other at the edge of the card. */
|
||||
@@ -591,6 +627,10 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
|
||||
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
|
||||
.nav-sub { display: flex; margin: 0 }
|
||||
.nav-sub .nav-item { padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
@@ -695,6 +735,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 }
|
||||
@@ -714,6 +756,39 @@ 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: 16px }
|
||||
.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-result-head { margin: 0 0 10px }
|
||||
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
|
||||
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
|
||||
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
|
||||
.nav-sub .nav-item:hover { color: var(--shell-ink) }
|
||||
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
|
||||
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
|
||||
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
|
||||
.mail-current-source { flex-basis: 100%; font-size: 12px }
|
||||
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
|
||||
.mail-grid label { margin: 0 0 7px }
|
||||
.mail-grid > div { margin-bottom: 4px }
|
||||
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
|
||||
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
|
||||
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
|
||||
.mail-entry:first-child { border-top: 0; padding-top: 4px }
|
||||
.mail-entry .pill { justify-self: start; margin-top: 1px }
|
||||
.mail-entry-text p { margin: 0 }
|
||||
.mail-entry-line { overflow-wrap: anywhere }
|
||||
.mail-entry-time { font-size: 12px; color: var(--muted) }
|
||||
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
|
||||
.mail-entry-detail { margin-top: 6px }
|
||||
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
|
||||
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
|
||||
@media (max-width: 560px) {
|
||||
.mail-entry { grid-template-columns: minmax(0, 1fr) }
|
||||
}
|
||||
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
|
||||
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
@@ -825,6 +900,9 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.nav-list > li:has(.nav-sub) { display: block }
|
||||
.nav-sub { display: grid; margin: 2px 0 4px }
|
||||
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Einwilligung ohne Neuladen (ADR 115).
|
||||
//
|
||||
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
|
||||
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
|
||||
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
|
||||
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
|
||||
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
|
||||
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
|
||||
// erst nach der Einwilligung im Dokument.
|
||||
//
|
||||
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
|
||||
// laden als eine Entscheidung, die nicht ankommt.
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
|
||||
if (forms.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replaceFrom = async () => {
|
||||
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
|
||||
if (!response.ok) {
|
||||
throw new Error('Seite nicht geladen');
|
||||
}
|
||||
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
|
||||
const next = fresh.getElementById(blocked.id);
|
||||
if (next && !next.classList.contains('embed-blocked')) {
|
||||
blocked.replaceWith(document.importNode(next, true));
|
||||
}
|
||||
}
|
||||
for (const legal of document.querySelectorAll('.block-legal[id]')) {
|
||||
const next = fresh.getElementById(legal.id);
|
||||
if (next) {
|
||||
legal.replaceWith(document.importNode(next, true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const form of forms) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = event.submitter;
|
||||
const body = new FormData(form, button);
|
||||
const placeholder = form.closest('.embed-blocked');
|
||||
for (const control of form.querySelectorAll('button')) {
|
||||
control.disabled = true;
|
||||
}
|
||||
try {
|
||||
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
|
||||
if (!saved.ok) {
|
||||
throw new Error('Entscheidung nicht gespeichert');
|
||||
}
|
||||
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
|
||||
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
|
||||
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
|
||||
await replaceFrom();
|
||||
}
|
||||
document.querySelector('.consent-bar')?.remove();
|
||||
if (placeholder) {
|
||||
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
|
||||
// seiner Stelle steht.
|
||||
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
|
||||
frame?.focus();
|
||||
} else {
|
||||
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
|
||||
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
|
||||
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
|
||||
}
|
||||
} catch {
|
||||
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
|
||||
for (const control of form.querySelectorAll('button')) {
|
||||
control.disabled = false;
|
||||
}
|
||||
withChoice(form, button).submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
|
||||
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
|
||||
function withChoice(form, button) {
|
||||
if (button?.name) {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'hidden';
|
||||
field.name = button.name;
|
||||
field.value = button.value;
|
||||
form.append(field);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
})();
|
||||
@@ -32,6 +32,11 @@ DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…`
|
||||
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||
|
||||
**Lora** und **Manrope** (Überschriften und Fließtext des Entwurfs „EBI Immobilien“) stehen unter
|
||||
der SIL Open Font License 1.1, Text in `lora-OFL.txt` und `manrope-OFL.txt`. Copyright 2011 The
|
||||
Lora Project Authors (github.com/cyrealtype/Lora-Cyrillic), Reserved Font Name „Lora“; Copyright
|
||||
2018 The Manrope Project Authors (github.com/googlefonts/manrope).
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
@@ -38,3 +38,11 @@
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -107,6 +107,10 @@ export const ICON_PATHS = {
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
// Soziale Netzwerke
|
||||
facebook: 'M15.5 4h-2.2A3.8 3.8 0 0 0 9.5 7.8V10H7v3.2h2.5V20h3.3v-6.8h2.4l.5-3.2h-2.9V8.2c0-.6.3-.9.9-.9h2Z',
|
||||
instagram: 'M8.1 3.5h7.8a4.6 4.6 0 0 1 4.6 4.6v7.8a4.6 4.6 0 0 1-4.6 4.6H8.1a4.6 4.6 0 0 1-4.6-4.6V8.1a4.6 4.6 0 0 1 4.6-4.6ZM12 8.1a3.9 3.9 0 1 0 0 7.8 3.9 3.9 0 0 0 0-7.8ZM17 7h.01',
|
||||
whatsapp: 'M4 20l1.4-4A8.5 8.5 0 1 1 8.6 19.1ZM9.3 9.2c0 3 2.5 5.5 5.5 5.5l1.3-1.4-2-.9-.9.8a4.1 4.1 0 0 1-2.3-2.3l.8-.9-.9-2Z',
|
||||
};
|
||||
|
||||
export const ICON_LABELS = {
|
||||
@@ -130,6 +134,7 @@ export const ICON_LABELS = {
|
||||
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
|
||||
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
|
||||
facebook: 'Facebook', instagram: 'Instagram', whatsapp: 'WhatsApp',
|
||||
};
|
||||
|
||||
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
|
||||
@@ -141,6 +146,7 @@ export const ICON_GROUPS = [
|
||||
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||
['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']],
|
||||
];
|
||||
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -264,6 +264,8 @@ body.consent-open{padding-bottom:320px}
|
||||
/* A single icon, sized by class because inline styles are forbidden by the policy. */
|
||||
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
|
||||
.block-icon svg{width:100%;height:100%;display:block}
|
||||
/* Der Strich aller Symbole (icon.html.twig) - einmal hier statt als Attribute an jedem einzelnen. */
|
||||
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
||||
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
||||
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
||||
.block-icon .block-icon{color:inherit}
|
||||
@@ -475,6 +477,12 @@ body.consent-open{padding-bottom:320px}
|
||||
.menu-collapse-mobile>.menu-list{display:none}
|
||||
}
|
||||
.menu-burger>summary{min-height:44px;min-width:44px}
|
||||
/* Ein Klick neben das aufgeklappte Menue schliesst es - ohne Skript: Solange es offen ist, liegt
|
||||
eine unsichtbare Flaeche ueber der Seite, die zum Menue-Knopf gehoert. Ein Klick darauf ist ein
|
||||
Klick auf den Knopf, und der klappt zu. Die Flaeche des Menues liegt darueber und bleibt
|
||||
bedienbar; :where() haelt die Regel schwaecher als die Platzierung im Kopfbereich. */
|
||||
.menu-burger[open]>summary::before{content:'';position:fixed;top:0;right:0;bottom:0;left:0;z-index:29;cursor:default}
|
||||
:where(.menu-burger[open])>.menu-burger-body{position:relative;z-index:30}
|
||||
.menu-burger-body{overflow-wrap:anywhere}
|
||||
/* Im Kopfbereich klappt das Menue als Flaeche unter dem Kopf auf, ueber die volle Breite.
|
||||
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
|
||||
|
||||
@@ -69,7 +69,8 @@ try {
|
||||
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
|
||||
const html = (await response.text()).length;
|
||||
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
|
||||
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
|
||||
// consent.js zählt nicht: nur solange eine Einwilligung offen ist (ADR 115).
|
||||
const scripts = [...transferred.entries()].filter(([url, entry]) => entry.type.includes('javascript') && !url.includes('/assets/consent.js')).length;
|
||||
const requests = transferred.size;
|
||||
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
|
||||
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
|
||||
@@ -114,7 +115,9 @@ try {
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
|
||||
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
|
||||
// consent.js zählt nicht mit: Es steht nur auf der Seite, solange eine Einwilligung offen ist,
|
||||
// und nach der Entscheidung nicht mehr (ADR 115).
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"]):not([src^="/assets/consent.js"])').count();
|
||||
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
|
||||
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
|
||||
@@ -162,7 +165,8 @@ try {
|
||||
const hinweis = page.locator('.consent-bar button[value="none"]');
|
||||
if (await hinweis.count()) {
|
||||
await hinweis.click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
// Mit consent.js ohne Neuladen (ADR 115): warten, bis der Hinweis weg ist.
|
||||
await page.locator('.consent-bar').waitFor({ state: 'detached' });
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
|
||||
@@ -171,7 +175,8 @@ try {
|
||||
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
|
||||
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
|
||||
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
|
||||
const ersterLink = page.locator('main a[href^="/"]').first();
|
||||
// Ein PDF zum Herunterladen ist keine Seite mit Überschrift; es zählt hier nicht mit.
|
||||
const ersterLink = page.locator('main a[href^="/"]:not([href$=".pdf"])').first();
|
||||
const ziel = await ersterLink.getAttribute('href');
|
||||
await ersterLink.click();
|
||||
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
|
||||
@@ -189,7 +194,7 @@ try {
|
||||
await noJs.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
assert.equal(await noJs.locator('h1').count(), 1);
|
||||
const ohneJs = noJs.locator('main a[href^="/"]').first();
|
||||
const ohneJs = noJs.locator('main a[href^="/"]:not([href$=".pdf"])').first();
|
||||
const zielOhneJs = await ohneJs.getAttribute('href');
|
||||
await ohneJs.click();
|
||||
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder von ebi-immobilien.de für die Mediathek des Nachbaus vor.
|
||||
*
|
||||
* Quelle sind die Originaldateien der laufenden Website (wp-content/uploads, am 13.10.2026
|
||||
* abgerufen). Daraus entstehen:
|
||||
* - logo.png: das Logo „EBI Immobilien“ ohne Rand, 680 px breit (Original 1361 × 407),
|
||||
* - favicon.png: das Seitensymbol der Website (192 × 192),
|
||||
* - startbild.webp: „startbild.*“ aus dem Quellordner, sonst das Wohnquartier aus dem Original-Hero,
|
||||
* - bewertung.webp: Ordner, Rechner und Stift (Original „workbook“), auf 1600 px verkleinert,
|
||||
* - schreibtisch.webp: Schreibtisch mit Brille und Notizblock (Original „services-h-fig“).
|
||||
*
|
||||
* Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit den Originalen> <Zielordner>
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/EBI.png')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit EBI.png usw.> <Zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** Ein Ausschnitt, auf eine Zielbreite verkleinert, als WebP. */
|
||||
$webp = static function (string $datei, string $name, int $x, int $y, int $b, int $h, int $breite) use ($quelle, $ziel): void {
|
||||
$pfad = $quelle.'/'.$datei;
|
||||
$bild = str_ends_with($datei, '.png') ? imagecreatefrompng($pfad) : imagecreatefromjpeg($pfad);
|
||||
if (false === $bild) {
|
||||
fwrite(STDERR, "Bild lässt sich nicht lesen: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, $x, $y, $breite, $hoehe, $b, $h);
|
||||
imagewebp($neu, $ziel.'/'.$name, 78);
|
||||
echo "$name: {$breite} × {$hoehe}\n";
|
||||
};
|
||||
|
||||
// Startbild: liegt im Quellordner eine Datei „startbild.jpg/.png/.webp“ (z. B. ein neu erzeugtes
|
||||
// Bild), wird sie auf 1920 px Breite und höchstens 16:9 gebracht. Sonst das Original-Hero, das oben
|
||||
// einen 116 px hohen grauen Balken trägt (Zuschnitt des Themes).
|
||||
$eigenes = glob($quelle.'/startbild.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
|
||||
if ([] !== $eigenes) {
|
||||
$pfad = $eigenes[0];
|
||||
$bild = match (strtolower(pathinfo($pfad, PATHINFO_EXTENSION))) {
|
||||
'png' => imagecreatefrompng($pfad),
|
||||
'webp' => imagecreatefromwebp($pfad),
|
||||
default => imagecreatefromjpeg($pfad),
|
||||
};
|
||||
$b = imagesx($bild);
|
||||
$h = min(imagesy($bild), (int) round($b * 9 / 16));
|
||||
$y = (int) round((imagesy($bild) - $h) / 2);
|
||||
$breite = min(1920, $b);
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, 0, $y, $breite, $hoehe, $b, $h);
|
||||
imagewebp($neu, $ziel.'/startbild.webp', 78);
|
||||
echo "startbild.webp aus ".basename($pfad).": {$breite} × {$hoehe}\n";
|
||||
} else {
|
||||
$webp('karlsruhe-3518473_1920-e1600241476184.png', 'startbild.webp', 0, 118, 1920, 722, 1920);
|
||||
}
|
||||
$webp('workbook-1205044_1920-1920x1280.jpg', 'bewertung.webp', 0, 0, 1920, 1280, 1600);
|
||||
$webp('services-h-fig-1920x1280.jpg', 'schreibtisch.webp', 0, 0, 1920, 1280, 1600);
|
||||
|
||||
// Logo: durchsichtigen Rand abschneiden und verkleinern.
|
||||
$logo = imagecreatefrompng($quelle.'/EBI.png');
|
||||
[$links, $oben, $rechts, $unten] = [imagesx($logo), imagesy($logo), 0, 0];
|
||||
for ($y = 0; $y < imagesy($logo); ++$y) {
|
||||
for ($x = 0; $x < imagesx($logo); ++$x) {
|
||||
if ((imagecolorat($logo, $x, $y) >> 24 & 0x7F) < 120) {
|
||||
$links = min($links, $x);
|
||||
$rechts = max($rechts, $x);
|
||||
$oben = min($oben, $y);
|
||||
$unten = max($unten, $y);
|
||||
}
|
||||
}
|
||||
}
|
||||
$b = $rechts - $links + 1;
|
||||
$h = $unten - $oben + 1;
|
||||
$breite = 680;
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagealphablending($neu, false);
|
||||
imagesavealpha($neu, true);
|
||||
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
|
||||
imagecopyresampled($neu, $logo, 0, 0, $links, $oben, $breite, $hoehe, $b, $h);
|
||||
imagepng($neu, $ziel.'/logo.png', 9);
|
||||
echo "logo.png: {$breite} × {$hoehe}\n";
|
||||
|
||||
copy($quelle.'/cropped-favicon-192x192.png', $ziel.'/favicon.png');
|
||||
echo "favicon.png übernommen\n";
|
||||
@@ -4,10 +4,15 @@
|
||||
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
|
||||
*
|
||||
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
|
||||
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
|
||||
* den Baum (ohne die Steine) als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
|
||||
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
|
||||
*
|
||||
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
|
||||
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner> [<Unterlagen>]
|
||||
*
|
||||
* Das dritte Argument ist Umuts Ordner „unterlagenfrwebsite“ (12.10.2026): Alpaka-Fotos, PQ-Logo,
|
||||
* Galabau-Signet und die drei PDFs. Sie bekommen hier feste, einfache Namen, damit das Seed-Skript
|
||||
* nicht an „Freistellungsbescheinigung §48 … bis 11.05.2029.pdf“ hängt. Kommt eine neue
|
||||
* Bescheinigung, genügt es, sie nach dem gleichen Muster benannt in den Ordner zu legen.
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
@@ -24,22 +29,76 @@ foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
|
||||
copy($datei, $ziel.'/'.basename($datei));
|
||||
}
|
||||
|
||||
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
|
||||
// Nur der Baum: Krone (grün) und Stamm (dunkel) aus dem linken Teil des Logos, ohne die grauen
|
||||
// Steine - die liefen bisher rechts aus dem Ausschnitt und sind in 32 px nur Rauschen (Umut,
|
||||
// 12.10.2026: „schneide bitte den Baum zurecht“). Der Stamm ist dunkler als die Steine.
|
||||
$logo = imagecreatefrompng($quelle.'/images/logo.png');
|
||||
if (false === $logo) {
|
||||
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$punkte = [];
|
||||
[$bx0, $by0, $bx1, $by1] = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
|
||||
for ($y = 10; $y < 520; ++$y) {
|
||||
for ($x = 10; $x < 500; ++$x) {
|
||||
$c = imagecolorat($logo, $x, $y);
|
||||
if ((($c >> 24) & 127) > 100) {
|
||||
continue;
|
||||
}
|
||||
[$r, $g, $b] = [($c >> 16) & 255, ($c >> 8) & 255, $c & 255];
|
||||
if ($g > $r + 30 || ($r + $g + $b) / 3 < 70) {
|
||||
$punkte[] = [$x, $y, $c];
|
||||
[$bx0, $by0, $bx1, $by1] = [min($bx0, $x), min($by0, $y), max($bx1, $x), max($by1, $y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
$baum = imagecreatetruecolor($bx1 - $bx0 + 1, $by1 - $by0 + 1);
|
||||
imagesavealpha($baum, true);
|
||||
imagealphablending($baum, false);
|
||||
imagefill($baum, 0, 0, imagecolorallocatealpha($baum, 0, 0, 0, 127));
|
||||
foreach ($punkte as [$x, $y, $c]) {
|
||||
imagesetpixel($baum, $x - $bx0, $y - $by0, $c);
|
||||
}
|
||||
$kante = 512;
|
||||
$symbol = imagecreatetruecolor($kante, $kante);
|
||||
imagesavealpha($symbol, true);
|
||||
imagealphablending($symbol, false);
|
||||
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
|
||||
imagealphablending($symbol, true);
|
||||
[$x, $y, $b, $h] = [15, 10, 490, 550];
|
||||
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
|
||||
[$b, $h] = [imagesx($baum), imagesy($baum)];
|
||||
$massstab = min(($kante - 32) / $b, ($kante - 32) / $h);
|
||||
$zb = (int) round($b * $massstab);
|
||||
$zh = (int) round($h * $massstab);
|
||||
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
|
||||
imagecopyresampled($symbol, $baum, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), 0, 0, $zb, $zh, $b, $h);
|
||||
imagepng($symbol, $ziel.'/favicon.png');
|
||||
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
|
||||
|
||||
$unterlagen = rtrim($argv[3] ?? '', '/\\');
|
||||
if ('' === $unterlagen) {
|
||||
exit(0);
|
||||
}
|
||||
// Muster im Ordner => fester Name. Bei mehreren Treffern gewinnt der letzte in Namensreihenfolge.
|
||||
$feste = [
|
||||
'Alpaka (1).jpeg' => 'alpaka-plakat.jpg',
|
||||
'Alpaka (2).jpeg' => 'alpaka-wiese.jpg',
|
||||
'Alpaka (3).jpeg' => 'alpaka-stall.jpg',
|
||||
'Alpaka (4).jpeg' => 'alpaka-blume.jpg',
|
||||
'Alpaka (6).jpeg' => 'alpaka-zaunbau.jpg',
|
||||
'Alpaka (9).jpeg' => 'alpaka-rasen.jpg',
|
||||
'PQ-Logo*.png' => 'pq-logo.png',
|
||||
'Signum*.png' => 'galabau-signet.png',
|
||||
'100-emoji.png' => 'hundert.png',
|
||||
'PQ-Bescheinigung*.pdf' => 'pq-bescheinigung.pdf',
|
||||
'Freistellungsbescheinigung §13*.pdf' => 'freistellung-13b.pdf',
|
||||
'Freistellungsbescheinigung §48*.pdf' => 'freistellung-48b.pdf',
|
||||
];
|
||||
$dateien = scandir($unterlagen) ?: [];
|
||||
foreach ($feste as $muster => $name) {
|
||||
$treffer = array_values(array_filter($dateien, static fn (string $d): bool => fnmatch($muster, $d, FNM_NOESCAPE)));
|
||||
if ([] === $treffer) {
|
||||
fwrite(STDERR, "Fehlt in den Unterlagen: $muster\n");
|
||||
exit(1);
|
||||
}
|
||||
sort($treffer);
|
||||
copy($unterlagen.'/'.end($treffer), $ziel.'/'.$name);
|
||||
}
|
||||
echo 'Unterlagen übernommen: '.count($feste)." Dateien\n";
|
||||
@@ -67,8 +67,8 @@ echo "Fassung {$version}\n";
|
||||
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
|
||||
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
|
||||
// dort, wo eine frische Installation ihre Tabellen anlegen will.
|
||||
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
|
||||
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example', '.htaccess'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak', 'zentrale'];
|
||||
|
||||
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
|
||||
$count = 0;
|
||||
@@ -116,6 +116,8 @@ if (!$ok) {
|
||||
exit(1);
|
||||
}
|
||||
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
|
||||
// vendor/ gehört nicht ins Netz, auch wenn die Domain falsch zeigt (ADR 116).
|
||||
copy($root.'/config/schutz/sperren.htaccess', $staging.'/vendor/.htaccess');
|
||||
|
||||
// The environment file is not part of a release: it holds the secret of one installation.
|
||||
foreach (['.env.local', '.env.prod.local'] as $secret) {
|
||||
|
||||
+28
-1
@@ -17,6 +17,9 @@
|
||||
* Eintrag wie ../../.env landet nirgends.
|
||||
* - Sie arbeitet nur in ein leeres Verzeichnis. Eine bestehende Installation wird nicht
|
||||
* überschrieben, auch nicht teilweise.
|
||||
* - Nach dem Entpacken legt sie den Installationsschlüssel an (var/install.key, ADR 116) und
|
||||
* zeigt ihn nur auf ihrer Abschlussseite. Ohne ihn öffnet sich der Installer nicht - wer die
|
||||
* frische Installation zuerst aufruft, kann sie so nicht übernehmen.
|
||||
*/
|
||||
|
||||
// Vom Buildskript gesetzt: `php scripts/build-release.php` schreibt Adresse und Prüfsumme des
|
||||
@@ -176,6 +179,27 @@ function kant_checks(string $target): array
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/** Legt den Installationsschlüssel an (oder liest einen vorhandenen) und gibt ihn zurück. */
|
||||
function kant_install_key(string $target): string
|
||||
{
|
||||
$file = $target.'/var/install.key';
|
||||
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
|
||||
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
|
||||
return $key;
|
||||
}
|
||||
if (!is_dir($target.'/var')) {
|
||||
mkdir($target.'/var', 0775, true);
|
||||
}
|
||||
$key = bin2hex(random_bytes(16));
|
||||
if (false === file_put_contents($file, $key."
|
||||
")) {
|
||||
throw new RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben (var/install.key).');
|
||||
}
|
||||
@chmod($file, 0600);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */
|
||||
function kant_run(string $target): array
|
||||
{
|
||||
@@ -192,6 +216,7 @@ function kant_run(string $target): array
|
||||
return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.'];
|
||||
}
|
||||
$files = kant_unpack($archive, $target);
|
||||
kant_install_key($target);
|
||||
|
||||
return [true, 'Entpackt: '.$files.' Dateien.'];
|
||||
} catch (Throwable $e) {
|
||||
@@ -259,7 +284,9 @@ header('X-Robots-Tag: noindex, nofollow');
|
||||
<h1>Die Dateien liegen.</h1>
|
||||
<p class="lead">Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.</p>
|
||||
<p class="notice done"><?= $e($message) ?></p>
|
||||
<p><a href="install">Zur Einrichtung →</a></p>
|
||||
<?php $key = kant_install_key($target); ?>
|
||||
<p>Dein Installationsschlüssel: <code><?= $e($key) ?></code><br><span style="font-size:13px;color:#5d6b62">Nur diese Seite zeigt ihn an. Der Verweis unten trägt ihn schon; liegt er dir nicht mehr vor, steht er in <code>var/install.key</code>.</span></p>
|
||||
<p><a href="install/schluessel?schluessel=<?= $e($key) ?>" rel="noreferrer">Zur Einrichtung →</a></p>
|
||||
<p style="font-size:13px;color:#5d6b62">Führt der Verweis ins Leere, zeigt die Domain nicht auf <code>public/</code>. Das muss beim Hoster eingestellt werden – ohne das wären auch <code>.env</code> und <code>var/</code> über das Netz erreichbar.</p>
|
||||
<?php } else { ?>
|
||||
<h1>KantSites installieren</h1>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Wirft die EBI-Immobilien-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument (optional): Ordner mit den Originalbildern von ebi-immobilien.de
|
||||
# (EBI.png, cropped-favicon-192x192.png, Hero- und Fotodateien aus wp-content/uploads).
|
||||
# Liegt dort eine startbild.jpg/.png/.webp, ersetzt sie das Startbild; Alternativtext per EBI_STARTBILD_ALT.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/ebi-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/ebi-immobilien-media"
|
||||
assets="$root/var/ebi-immobilien-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-ebi-immobilien-assets.php "$1" "$assets"
|
||||
fi
|
||||
if [ ! -f "$assets/startbild.webp" ] || [ ! -f "$assets/logo.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Ordner der Originalbilder als Argument aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/ebi-immobilien.db"
|
||||
rm -rf "$root/var/ebi-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-ebi-immobilien.php "$assets"
|
||||
# Manrope und Lora vom eigenen Server; der Abruf ergaenzt fonts.css nur.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
|
||||
rm -f "$root/var/ebi-immobilien-paket.zip"
|
||||
"$php" bin/console cms:paket:export --ziel="$root/var/ebi-immobilien-paket.zip"
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
|
||||
# Zweites Argument: Umuts Unterlagen (Alpakas, PQ, Freistellungsbescheinigungen, Signet).
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
@@ -8,10 +9,10 @@ export DATABASE_URL="sqlite:///$root/var/huefner.db"
|
||||
export MEDIA_DIRECTORY="$root/var/huefner-media"
|
||||
assets="$root/var/huefner-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-huefner-assets.php "$1" "$assets"
|
||||
"$php" scripts/build-huefner-assets.php "$1" "$assets" "$2"
|
||||
fi
|
||||
if [ ! -f "$assets/favicon.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
|
||||
if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/pq-bescheinigung.pdf" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs und den Unterlagen als Argumente aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/huefner.db"
|
||||
@@ -24,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,978 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut den Redesign-Entwurf „EBI Immobilien“ (Karlsruhe) als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist die laufende WordPress-Seite www.ebi-immobilien.de (am 13.10.2026 abgelesen): Start
|
||||
* mit Karlsruher Wohnquartier im Hero, „Was bieten wir Ihnen“ in drei Punkten, Suchauftrag,
|
||||
* Immobilienbewertung, Kontakt, Impressum und Datenschutz. Logo, Bilder, Formularfelder und
|
||||
* Kontaktdaten sind daraus übernommen; Gestaltung und Texte sind neu: Navy und das Logo-Blau,
|
||||
* Sandton als warme Fläche, Lora für Überschriften, Manrope für Text.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - Die Immobilienliste des Originals ist leer („0 Results“) und hängt an einem Theme mit
|
||||
* Kundenkonten. Der Nachbau hat keine Objektliste; die Startseite führt stattdessen zu
|
||||
* Suchauftrag und Bewertung. Alte Adressen leiten weiter.
|
||||
* - Suchauftrag und Bewertung sind CMS-Formulare in Schritten mit Zusammenfassung statt
|
||||
* Contact Form 7. „Kaufen oder mieten“ ist beim Suchauftrag neu; die Bewertungsfragen zu
|
||||
* Zimmern, Baujahr und Küche entfallen bei einem Grundstück.
|
||||
* - Kein Cookie-Banner: die Seite lädt nichts von Dritten.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-ebi-immobilien.sh).
|
||||
* Erstes Argument: der Ordner aus build-ebi-immobilien-assets.php.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'ebi-immobilien')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die EBI-Immobilien-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$redirects = new RedirectRepository($db);
|
||||
$theme = new Theme($db);
|
||||
|
||||
// Angaben aus dem Impressum von ebi-immobilien.de (13.10.2026).
|
||||
$firma = 'EBI Immobilien';
|
||||
$strasse = 'Hopfenstraße 10b';
|
||||
$plz = '76185';
|
||||
$ort = 'Karlsruhe';
|
||||
$telefon = '0172 6110975';
|
||||
$tel = 'tel:+491726110975';
|
||||
$mail = 'info@ebi-immobilien.de';
|
||||
$SCHEMA = DocumentValidator::SCHEMA_VERSION;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = '', string $breite = 'normal', string $sichtbar = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
$layout = [];
|
||||
if ('normal' !== $breite) {
|
||||
$layout['width'] = $breite;
|
||||
}
|
||||
if ('all' !== $sichtbar) {
|
||||
$layout['visibility'] = $sichtbar;
|
||||
}
|
||||
if ([] !== $layout) {
|
||||
$block['layout'] = $layout;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']];
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], string $css = '') use ($row): array {
|
||||
// Ein eigener Rand ergänzt den oberen Nullrand, statt ihn zu ersetzen (sonst greift der
|
||||
// Standardabstand einer Zeile).
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, ...$extra, 'margin' => ['top' => '0px', ...($extra['margin'] ?? [])]]];
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style, $css);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static fn (string $id, string $name, int $groesse, array $style = []): array => ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
|
||||
/**
|
||||
* Ein Button: Pfeil, der beim Überfahren nach rechts gleitet, und ein leichtes Anheben.
|
||||
*
|
||||
* @param string $art blau (Theme), weiss (Weiß auf dunkel), rahmen (Navy-Rahmen auf hell), hell (Rahmen auf dunkel)
|
||||
*/
|
||||
$knopf = static function (string $id, string $inhalt, string $href, string $art = 'blau', array $style = [], string $icon = 'arrow-right'): array {
|
||||
$farbe = [
|
||||
'blau' => '',
|
||||
'weiss' => "background:#fff;color:#14273a;\n&:hover{background:#eaf5fb;color:#14273a}\n",
|
||||
'rahmen' => "background:transparent;color:#14273a;box-shadow:inset 0 0 0 1.5px #14273a;\n&:hover{background:#14273a;color:#fff}\n",
|
||||
'hell' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.6);\n&:hover{background:rgba(255,255,255,.12);color:#fff}\n",
|
||||
][$art];
|
||||
$props = ['text' => $inhalt, 'href' => $href, 'icon' => $icon];
|
||||
if ('phone' === $icon) {
|
||||
$props['iconPosition'] = 'start';
|
||||
} elseif ('none' !== $icon) {
|
||||
$props['iconPosition'] = 'end';
|
||||
$props['iconMotion'] = 'slide';
|
||||
}
|
||||
|
||||
return ['id' => $id, 'type' => 'button', 'props' => $props, 'css' => "min-height:52px;padding:0 26px;margin-top:0;transition:transform .2s ease,background-color .2s ease,color .2s ease;\n".$farbe.'&:hover{transform:translateY(-2px)}']
|
||||
+ ([] === $style ? [] : ['style' => $style]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
$linkCss = "& a{color:#0b6aa2;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#14273a}";
|
||||
|
||||
/** Die Kleinzeile: kurze blaue Linie davor, Versalien. */
|
||||
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@blau', string $unten = '18px') => $text($id, $inhalt, ['desktop' => [
|
||||
'fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'lineHeight' => 1.6, 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten],
|
||||
]], "display:flex;align-items:center;gap:12px\n&::before{content:'';width:28px;height:2px;background:#2ea8e0;flex-shrink:0}");
|
||||
|
||||
$h2Stil = [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '44px', 'fontWeight' => '500', 'lineHeight' => 1.15, 'letterSpacing' => '-0.01em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
|
||||
'tablet' => ['fontSize' => '38px'],
|
||||
'mobile' => ['fontSize' => '31px'],
|
||||
];
|
||||
$h2Hell = [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'color' => '@papier']];
|
||||
$fliess = static fn (string $farbe = '@text', string $unten = '0px', string $breite = 'none', string $groesse = '17px') => [
|
||||
'desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.75, 'color' => $farbe, 'maxWidth' => $breite, 'margin' => ['top' => '0px', 'bottom' => $unten]],
|
||||
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
|
||||
];
|
||||
|
||||
// Ein Link über die ganze Karte: Die Karte ist positioniert, der Link spannt sein ::after auf.
|
||||
$ganzeKarte = "position:relative;\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #2ea8e0;outline-offset:4px}";
|
||||
|
||||
/** Ein Haken vor einer Zeile – für Aufzählungen in Abschnitten. */
|
||||
$haken = static fn (string $id, string $inhalt, string $farbe = '@text', string $hakenfarbe = '@blau') => $zeile($id, [
|
||||
$symbol($id.'-icon', 'check-circle', 24, ['desktop' => ['color' => $hakenfarbe, 'flexShrink' => 0]]),
|
||||
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => $farbe, 'margin' => ['bottom' => '0px']]], "& strong{font-weight:700}
|
||||
".$linkCss),
|
||||
], '12px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '14px']]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => $firma, 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'tinte', 'name' => 'Navy (Überschriften, dunkle Flächen)', 'value' => '#14273a'],
|
||||
['key' => 'navy-tief', 'name' => 'Navy tief (Fußleiste)', 'value' => '#0e1c2a'],
|
||||
['key' => 'blau', 'name' => 'Blau (Knöpfe, kleine Schrift)', 'value' => '#0b6aa2'],
|
||||
['key' => 'blau-dunkel', 'name' => 'Blau dunkel (Knöpfe beim Überfahren)', 'value' => '#08527d'],
|
||||
['key' => 'logo-blau', 'name' => 'Logo-Blau (Linien, Akzente auf Navy)', 'value' => '#2ea8e0'],
|
||||
['key' => 'himmel', 'name' => 'Hellblau (Schrift auf Navy)', 'value' => '#7fd0f2'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#4d5966'],
|
||||
['key' => 'sand', 'name' => 'Sand (warme Flächen)', 'value' => '#f5f1ea'],
|
||||
['key' => 'linie', 'name' => 'Linien', 'value' => '#e3ddd2'],
|
||||
['key' => 'auf-dunkel', 'name' => 'Schrift auf Navy', 'value' => '#c3cfdb'],
|
||||
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
['key' => 'body', 'name' => 'Text', 'family' => 'Manrope', 'source' => 'google', 'weights' => [400, 500, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lora', 'source' => 'google', 'weights' => [500, 600], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '17px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@blau', 'containerWidth' => '1200px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '6px'],
|
||||
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'body', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.png', 'EBI Immobilien'],
|
||||
'favicon' => ['favicon.png', 'Seitensymbol von EBI Immobilien'],
|
||||
'karlsruhe' => ['startbild.webp', getenv('EBI_STARTBILD_ALT') ?: 'Modernes Wohnquartier in der Abenddämmerung mit beleuchteten Balkonen und Rasenfläche'],
|
||||
'bewertung' => ['bewertung.webp', 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch'],
|
||||
'schreibtisch' => ['schreibtisch.webp', 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Formulare – Felder wie auf ebi-immobilien.de, in Schritte geteilt
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$einwilligung = ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass EBI Immobilien meine Angaben zur Bearbeitung meiner Anfrage speichert und mich kontaktiert.'];
|
||||
|
||||
$forms->save('suchauftrag', 'Suchauftrag', [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Wunschimmobilie', 'help' => 'Was suchen Sie? Bis auf die Art der Immobilie ist alles freiwillig.'],
|
||||
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Kaufen oder mieten?', 'required' => true, 'options' => ['Kaufen', 'Mieten']],
|
||||
['name' => 'art', 'type' => 'radio', 'label' => 'Art der Immobilie', 'required' => true, 'options' => ['Wohnung', 'Einfamilienhaus', 'Mehrfamilienhaus']],
|
||||
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => false, 'min' => 1, 'max' => 50],
|
||||
['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche ab', 'required' => false, 'min' => 1, 'max' => 10000, 'unit' => 'm²'],
|
||||
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr ab', 'required' => false, 'min' => 1800, 'max' => 2100],
|
||||
['name' => 'preisspanne', 'type' => 'text', 'label' => 'Preisspanne', 'required' => false, 'help' => 'z. B. bis 450.000 € oder 900 bis 1.200 € warm'],
|
||||
['name' => 'sonstiges', 'type' => 'textarea', 'label' => 'Sonstiges', 'required' => false, 'help' => 'Wunschlage, Balkon, Garten, Stellplatz …'],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir Ihnen passende Angebote schicken können.'],
|
||||
['name' => 'anrede', 'type' => 'radio', 'label' => 'Anrede', 'required' => true, 'options' => ['Herr', 'Frau']],
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Vor- und Nachname', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'strasse', 'type' => 'text', 'label' => 'Straße und Hausnummer', 'required' => false],
|
||||
['name' => 'ort', 'type' => 'text', 'label' => 'PLZ und Ort', 'required' => false],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neuer Suchauftrag über die Website', 'success' => 'Vielen Dank! Ihr Suchauftrag ist bei uns angekommen. Wir melden uns persönlich bei Ihnen, sobald wir ein passendes Objekt haben.', 'replyToField' => 'email', 'buttonLabel' => 'Suchauftrag senden', 'summary' => true], 0);
|
||||
echo "Formular angelegt: suchauftrag\n";
|
||||
|
||||
$gebaeude = ['Wohnung', 'Haus', 'Mehrfamilienhaus'];
|
||||
$forms->save('bewertung', 'Immobilienbewertung', [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Worum geht es?'],
|
||||
['name' => 'objekt', 'type' => 'radio', 'label' => 'Was möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Mehrfamilienhaus', 'Grundstück']],
|
||||
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Verkaufen', 'Vermieten']],
|
||||
|
||||
['name' => 'schritt_details', 'type' => 'step', 'label' => 'Eckdaten', 'help' => 'Ungefähre Angaben genügen für eine erste Einschätzung.'],
|
||||
['name' => 'flaeche', 'type' => 'number', 'label' => 'Fläche', 'required' => true, 'min' => 1, 'max' => 100000, 'unit' => 'm²',
|
||||
'help' => 'Wohnfläche – bei einem Grundstück die Grundstücksfläche'],
|
||||
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr', 'required' => true, 'min' => 1700, 'max' => 2100, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => true, 'min' => 1, 'max' => 200, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'kueche', 'type' => 'radio', 'label' => 'Ist eine Einbauküche vorhanden?', 'required' => true, 'options' => ['Ja – gebraucht', 'Ja – neuwertig', 'Keine vorhanden'], 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'parken', 'type' => 'select', 'label' => 'Welche Parkmöglichkeiten gibt es?', 'required' => true, 'options' => ['Tiefgarage', 'Garage', 'Doppelgarage', 'Carport', 'Stellplatz', 'Mehrere', 'Keine']],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Wir melden uns mit einer Einschätzung persönlich bei Ihnen.'],
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Freiwillig – wenn wir Sie lieber anrufen sollen'],
|
||||
['name' => 'lage', 'type' => 'text', 'label' => 'Lage der Immobilie', 'required' => false, 'help' => 'Ort oder Stadtteil, gern mit Straße'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Bewertungsanfrage über die Website', 'success' => 'Vielen Dank! Ihre Bewertungsanfrage ist bei uns angekommen. Wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Bewertung anfragen', 'summary' => true], 0);
|
||||
echo "Formular angelegt: bewertung\n";
|
||||
|
||||
$forms->save('kontakt', 'Kontakt', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false],
|
||||
['name' => 'betreff', 'type' => 'text', 'label' => 'Betreff', 'required' => false],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über die Website', 'success' => 'Vielen Dank für Ihre Nachricht. Wir melden uns so bald wie möglich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: Navy-Leiste mit Kontakt (nur Desktop), darunter weiß mit Logo, Menü, Menü-Button
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$leistenLink = "& a{color:#dbe5ee;text-decoration:none;transition:color .2s}\n& a:hover{color:#fff}";
|
||||
$leistenPunkt = static fn (string $id, string $icon, string $inhalt) => $zeile($id, [
|
||||
$symbol($id.'-icon', $icon, 16, ['desktop' => ['color' => '@himmel']]),
|
||||
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]], $leistenLink),
|
||||
], '8px');
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf-leiste', [
|
||||
$zeile('kopf-leiste-zeile', [
|
||||
$text('kopf-leiste-spruch', 'Ihr Immobilienmakler in Karlsruhe und Umgebung', ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('kopf-leiste-kontakt', [
|
||||
$leistenPunkt('kopf-leiste-telefon', 'phone', '<a href="'.$tel.'">'.$telefon.'</a>'),
|
||||
$leistenPunkt('kopf-leiste-mail', 'mail', '<a href="mailto:'.$mail.'">'.$mail.'</a>'),
|
||||
], '28px'),
|
||||
], '20px', ['justifyContent' => 'space-between', 'minHeight' => '40px']),
|
||||
], ['desktop' => ['backgroundColor' => '@tinte', 'padding' => ['top' => '0px', 'bottom' => '0px']]], null, '', 'wide', 'hide-mobile'),
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$bild('kopf-logo', $medien['logo'], $firma.' – zur Startseite', [
|
||||
// Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS).
|
||||
'desktop' => ['width' => '200px', 'maxWidth' => '200px', 'flexShrink' => 0, 'margin' => $seiten('0px')],
|
||||
'mobile' => ['width' => '150px', 'maxWidth' => '150px'],
|
||||
], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '15px', 'fontWeight' => '600', 'color' => '@tinte',
|
||||
'burgerColor' => '@tinte', 'burgerBackgroundColor' => '@papier', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '6px',
|
||||
]],
|
||||
// Blaue Linie, die beim Überfahren von der Mitte aus wächst; das Klappmenü liegt
|
||||
// als Fläche unter dem ganzen Kopf.
|
||||
'css' => "& .menu-list{gap:4px 34px;align-items:center}\n"
|
||||
."& .menu-list a{position:relative;padding:8px 0;text-decoration:none}\n"
|
||||
."& .menu-list a::after{content:'';position:absolute;left:50%;right:50%;bottom:0;height:2px;background:#2ea8e0;transition:left .25s ease,right .25s ease}\n"
|
||||
."& .menu-list a:hover::after{left:0;right:0}\n"
|
||||
."& .menu-list .menu-button{padding:12px 22px}\n"
|
||||
."& .menu-list .menu-button::after{display:none}\n"
|
||||
."& .menu-burger{margin-left:auto}\n"
|
||||
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
|
||||
."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #e3ddd2;box-shadow:0 18px 30px rgba(20,39,58,.12);font-size:18px}\n"
|
||||
."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n"
|
||||
."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #eee8de}\n"
|
||||
.'& .menu-burger-body .menu-list a::after{display:none}'],
|
||||
], '28px', ['justifyContent' => 'space-between', 'minHeight' => '88px'], ['minHeight' => '70px', 'gap' => '6px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '#fffffff7', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'shadowY' => '1px', 'shadowBlur' => '0px', 'shadowColor' => '#14273a1a'],
|
||||
], null, '', 'wide'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich: Sand mit blauer Oberkante, Navy-Leiste unten
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fussLink = "& a{color:#4d5966;text-decoration:none;transition:color .2s}\n& a:hover{color:#0b6aa2}";
|
||||
$fussTitel = static fn (string $id, string $inhalt) => $heading($id, $inhalt, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '19px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '14px']]]);
|
||||
$fussText = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.95, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $fussLink."\n& a{overflow-wrap:anywhere}");
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$column('fuss-raster', [
|
||||
$column('fuss-marke', [
|
||||
$bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '190px', 'maxWidth' => '190px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
$text('fuss-spruch', 'Wir helfen Ihnen, Ihr neues Zuhause zu finden – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'maxWidth' => '300px', 'margin' => ['top' => '20px', 'bottom' => '0px']], 'mobile' => ['maxWidth' => 'none']]),
|
||||
]),
|
||||
$column('fuss-service', [
|
||||
$fussTitel('fuss-service-titel', 'Service'),
|
||||
$fussText('fuss-service-liste', '<a href="/suchauftrag">Suchauftrag erstellen</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/#leistungen">Unsere Leistungen</a>'),
|
||||
]),
|
||||
$column('fuss-kontakt', [
|
||||
$fussTitel('fuss-kontakt-titel', 'Kontakt'),
|
||||
$fussText('fuss-kontakt-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'<br><a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a>'),
|
||||
]),
|
||||
$column('fuss-anfrage', [
|
||||
$fussTitel('fuss-anfrage-titel', 'Fragen?'),
|
||||
$text('fuss-anfrage-text', 'Schreiben Sie uns – wir melden uns persönlich.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '18px']]]),
|
||||
$knopf('fuss-anfrage-knopf', 'Nachricht schreiben', '/kontakt', 'rahmen'),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px', 'padding' => ['top' => '64px', 'bottom' => '52px']],
|
||||
'tablet' => ['gridColumns' => 2],
|
||||
'mobile' => ['gridColumns' => 1, 'gap' => '32px', 'padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
|
||||
]),
|
||||
$section('fuss-leiste', [
|
||||
$zeile('fuss-unten', [
|
||||
$text('fuss-copyright', '© 2026 '.$firma.', Karlsruhe', ['desktop' => ['fontSize' => '13px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('fuss-links', [
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '13px', 'gap' => '24px', 'color' => '@auf-dunkel']],
|
||||
'css' => "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Webdesign by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"),
|
||||
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'padding' => ['top' => '20px', 'bottom' => '22px']],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start']),
|
||||
], ['desktop' => ['backgroundColor' => '@navy-tief', 'padding' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Eine Leistung als Karte: Symbol im hellblauen Kreis, Titel, Satz, Textlink. */
|
||||
$leistung = static function (int $n, string $icon, string $titel, string $satz, string $link, string $href) use ($column, $zeile, $text, $heading, $symbol, $rund, $nurUnten, $ganzeKarte): array {
|
||||
$id = 'leistung-'.$n;
|
||||
|
||||
return $column($id, [
|
||||
$zeile($id.'-kopf', [
|
||||
$column($id.'-symbol', [$symbol($id.'-icon', $icon, 24, ['desktop' => ['color' => '@blau']])], ['desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '60px', 'height' => '60px', 'flexShrink' => 0,
|
||||
'backgroundColor' => '#e8f4fb', 'borderRadius' => $rund('50%'),
|
||||
]], 'transition:background-color .25s ease,color .25s ease'),
|
||||
], '12px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '26px']]),
|
||||
$heading($id.'-titel', $titel, 3, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '24px', 'fontWeight' => '600', 'lineHeight' => 1.25, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '12px']]]),
|
||||
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '24px']]]),
|
||||
$text($id.'-link', '<a href="'.$href.'">'.$link.'</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['top' => 'auto', 'bottom' => '0px']]],
|
||||
"& a{color:#0b6aa2;text-decoration:none;display:inline-flex;align-items:center;gap:8px}\n& a::before{content:'';order:2;width:18px;height:2px;background:currentColor;transition:width .25s ease}\n& a:hover{color:#14273a}"),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'backgroundColor' => '@papier', 'borderRadius' => $rund('8px'),
|
||||
'padding' => ['top' => '34px', 'right' => '32px', 'bottom' => '34px', 'left' => '32px'],
|
||||
'borderWidth' => $nurUnten('3px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff',
|
||||
'shadowY' => '10px', 'shadowBlur' => '30px', 'shadowColor' => '#14273a14'],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'right' => '24px', 'bottom' => '28px', 'left' => '24px']],
|
||||
], $ganzeKarte."\ntransition:transform .3s ease,box-shadow .3s ease,border-color .3s ease;\n&:hover{transform:translateY(-6px);border-bottom-color:#2ea8e0;box-shadow:0 22px 44px rgba(20,39,58,.14)}\n&:hover [id=\"{$id}-symbol\"]{background-color:#0b6aa2;color:#fff}\n&:hover [id=\"{$id}-icon\"]{color:#fff}\n&:hover a::before{width:30px}");
|
||||
};
|
||||
|
||||
/** Ein Schritt mit runder Ziffer. */
|
||||
$schritt = static fn (int $n, string $titel, string $satz) => $zeile('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', (string) $n, ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '40px', 'height' => '40px', 'flexShrink' => 0,
|
||||
'borderRadius' => $rund('50%'), 'borderWidth' => $seiten('1.5px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau',
|
||||
'fontFamily' => 'heading', 'fontSize' => '18px', 'fontWeight' => '600', 'color' => '@blau', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('schritt-'.$n.'-text', '<strong>'.$titel.'</strong>'.$satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]],
|
||||
'& strong{display:block;font-size:17px;font-weight:700;color:#14273a;margin-bottom:2px}'),
|
||||
], '18px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '22px']]);
|
||||
|
||||
/** Eine Kennzahl im Hero: großer Wert, kleine Beschriftung, Linie davor. */
|
||||
$fakt = static fn (string $id, string $wert, string $beschriftung) => $column($id, [
|
||||
$text($id.'-wert', $wert, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '30px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['bottom' => '6px']], 'mobile' => ['fontSize' => '24px']]),
|
||||
$text($id.'-text', $beschriftung, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
|
||||
], ['desktop' => ['borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '2px'], 'borderStyle' => 'solid', 'borderColor' => '@logo-blau', 'padding' => ['left' => '18px']]]);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Finden Sie Ihr zukünftiges Zuhause',
|
||||
'description' => 'EBI Immobilien in Karlsruhe: Wir helfen Ihnen beim Kauf oder der Miete Ihrer Wunschimmobilie und bewerten Ihre Immobilie für Verkauf oder Vermietung.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Karlsruher Wohnquartier, abgedunkelt, Text links, Kennzahlen unten.
|
||||
$section('hero', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-klein', 'Immobilien in Karlsruhe und Umgebung', '@himmel', '22px'),
|
||||
$heading('hero-titel', 'Finden Sie Ihr<br>zukünftiges Zuhause.', 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.08, 'letterSpacing' => '-0.015em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '24px']],
|
||||
'tablet' => ['fontSize' => '52px'],
|
||||
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '18px']],
|
||||
], 'text-shadow:0 2px 24px rgba(10,20,32,.35)'),
|
||||
$text('hero-satz', 'Ob Kauf oder Miete, Wohnung oder Haus: Wir begleiten Sie persönlich auf dem Weg zu Ihrer Wunschimmobilie – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', [
|
||||
'desktop' => ['fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '#eef3f7', 'maxWidth' => '600px', 'margin' => ['bottom' => '36px']],
|
||||
'mobile' => ['fontSize' => '17px', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$zeile('hero-knoepfe', [
|
||||
$knopf('hero-knopf-suche', 'Suchauftrag erstellen', '/suchauftrag'),
|
||||
$knopf('hero-knopf-bewertung', 'Immobilie bewerten lassen', '/immobilienbewertung', 'hell', [], 'none'),
|
||||
], '14px', ['flexWrap' => 'wrap']),
|
||||
$row('hero-fakten', [
|
||||
$fakt('hero-fakt-erfahrung', '15+ Jahre', 'Erfahrung bei Kauf und Miete'),
|
||||
$fakt('hero-fakt-region', 'Karlsruhe', 'und Umgebung'),
|
||||
$fakt('hero-fakt-persoenlich', 'Persönlich', 'ein fester Ansprechpartner'),
|
||||
], ['desktop' => ['columns' => 'three', 'gap' => 'medium'], 'mobile' => ['columns' => 'one', 'gap' => 'small']], [
|
||||
'desktop' => ['margin' => ['top' => '64px'], 'maxWidth' => '760px'],
|
||||
'mobile' => ['margin' => ['top' => '44px']],
|
||||
]),
|
||||
], ['desktop' => ['maxWidth' => '820px']]),
|
||||
], [
|
||||
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.15,
|
||||
'color' => '@papier', 'minHeight' => '82vh', 'display' => 'flex', 'alignItems' => 'center', 'padding' => ['top' => '110px', 'bottom' => '96px']],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
], null, "position:relative\n&::before{content:'';position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,rgba(13,28,44,.78) 0%,rgba(13,28,44,.5) 50%,rgba(13,28,44,.1) 85%);pointer-events:none}\n& .section-inner{position:relative;z-index:1}"),
|
||||
|
||||
// 2 – Leistungen auf Sand: Kopf, drei Karten aus „Was bieten wir Ihnen“.
|
||||
$section('leistungen-bereich', [
|
||||
$row('leistungen-kopf', [
|
||||
$column('leistungen-kopf-titel', [
|
||||
$kleinzeile('leistungen-klein', 'Was wir Ihnen bieten'),
|
||||
$heading('leistungen-titel', 'Kaufen, mieten, verkaufen –<br>mit einem Ansprechpartner.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'margin' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
$text('leistungen-satz', 'Seit über 15 Jahren unterstützen wir Käufer, Mieter und Eigentümer in Karlsruhe. Unkompliziert, ehrlich und mit Blick fürs Detail.', [
|
||||
'desktop' => ['maxWidth' => '380px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
|
||||
]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '20px', 'columnGap' => '48px', 'margin' => ['top' => '0px', 'bottom' => '48px']],
|
||||
'mobile' => ['margin' => ['bottom' => '32px']],
|
||||
]),
|
||||
$column('leistungen-raster', [
|
||||
$leistung(1, 'home', 'Ihr Traumhaus finden', 'Wir helfen Ihnen, das passende Zuhause zu finden – ob zum Kauf oder zur Miete. Sagen Sie uns, was Sie suchen.', 'Suchauftrag erstellen', '/suchauftrag'),
|
||||
$leistung(2, 'award', 'Langjährige Erfahrung', 'Seit über 15 Jahren begleiten wir Immobilienkäufe und Vermietungen. Von der Besichtigung bis zur Schlüsselübergabe.', 'Kontakt aufnehmen', '/kontakt'),
|
||||
$leistung(3, 'document', 'Immobilienbewertung', 'Sie möchten verkaufen oder vermieten? Wir schätzen den Wert Ihrer Immobilie realistisch ein.', 'Zur Bewertung', '/immobilienbewertung'),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '28px'],
|
||||
'tablet' => ['gridColumns' => 1, 'gap' => '20px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '100px', 'bottom' => '104px']],
|
||||
'mobile' => ['padding' => ['top' => '60px', 'bottom' => '60px']],
|
||||
], 'leistungen'),
|
||||
|
||||
// 3 – Suchauftrag: Foto mit versetztem Rahmen links, Text und drei Schritte rechts.
|
||||
$section('suche-bereich', [
|
||||
$row('suche-zeile', [
|
||||
$column('suche-bild-spalte', [
|
||||
$bild('suche-bild', $medien['schreibtisch'], 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock', [
|
||||
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '520px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
|
||||
'mobile' => ['imageHeight' => '300px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
$column('suche-etikett', [
|
||||
$symbol('suche-etikett-icon', 'search', 24, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text('suche-etikett-text', '<strong>Nicht fündig geworden?</strong>Wir suchen für Sie weiter.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]],
|
||||
'& strong{display:block;font-family:Lora,serif;font-size:18px;font-weight:600;color:#fff;margin-bottom:2px}'),
|
||||
], [
|
||||
'desktop' => ['position' => 'absolute', 'right' => '-28px', 'bottom' => '36px', 'display' => 'flex', 'gap' => '14px', 'alignItems' => 'flex-start', 'maxWidth' => '290px',
|
||||
'backgroundColor' => '@tinte', 'borderRadius' => $rund('8px'), 'padding' => ['top' => '20px', 'right' => '22px', 'bottom' => '20px', 'left' => '22px'],
|
||||
'shadowY' => '18px', 'shadowBlur' => '40px', 'shadowColor' => '#14273a40'],
|
||||
'tablet' => ['right' => '20px'],
|
||||
'mobile' => ['right' => '12px', 'left' => '12px', 'bottom' => '12px', 'maxWidth' => 'none'],
|
||||
]),
|
||||
], ['desktop' => ['position' => 'relative']], "&::before{content:'';position:absolute;top:-18px;left:-18px;width:55%;height:60%;border-top:3px solid #2ea8e0;border-left:3px solid #2ea8e0;border-top-left-radius:12px;pointer-events:none}"),
|
||||
$column('suche-text', [
|
||||
$kleinzeile('suche-klein', 'Immobilien-Suchauftrag'),
|
||||
$heading('suche-titel', 'Ihre Traumimmobilie war noch nicht dabei?', 2, $h2Stil),
|
||||
$text('suche-satz', 'Wir unterstützen Sie gern bei Ihrer Suche. Erzählen Sie uns, was Sie sich wünschen – wir halten die Augen offen und melden uns, sobald etwas Passendes dabei ist.', $fliess('@text', '32px', '520px')),
|
||||
$schritt(1, 'Wünsche angeben', 'Art, Größe, Lage und Budget – in zwei kurzen Schritten.'),
|
||||
$schritt(2, 'Wir suchen für Sie', 'Wir gleichen Ihre Wünsche mit unseren Objekten und dem Markt ab.'),
|
||||
$schritt(3, 'Persönliche Rückmeldung', 'Passt etwas, melden wir uns direkt bei Ihnen.'),
|
||||
$knopf('suche-knopf', 'Suchauftrag erstellen', '/suchauftrag', 'blau', ['desktop' => ['margin' => ['top' => '14px']]]),
|
||||
], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start']]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '96px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '112px', 'bottom' => '112px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
], 'suchauftrag'),
|
||||
|
||||
// 4 – Bewertung auf Navy: Text und Haken links, Foto rechts mit blauem Versatz.
|
||||
$section('bewertung-bereich', [
|
||||
$row('bewertung-zeile', [
|
||||
$column('bewertung-text', [
|
||||
$kleinzeile('bewertung-klein', 'Immobilienbewertung', '@himmel'),
|
||||
$heading('bewertung-titel', 'Was ist Ihre Immobilie wert?', 2, $h2Hell),
|
||||
$text('bewertung-satz', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Für eine erste Einschätzung genügen uns wenige Angaben:', $fliess('@auf-dunkel', '28px', '520px')),
|
||||
$haken('bewertung-punkt-1', '<strong>Art und Vorhaben</strong> – Wohnung, Haus oder Grundstück, Verkauf oder Vermietung', '#dbe5ee', '@logo-blau'),
|
||||
$haken('bewertung-punkt-2', '<strong>Eckdaten</strong> – Wohnfläche, Baujahr und Zimmer', '#dbe5ee', '@logo-blau'),
|
||||
$haken('bewertung-punkt-3', '<strong>Ausstattung</strong> – Einbauküche und Parkmöglichkeiten', '#dbe5ee', '@logo-blau'),
|
||||
$zeile('bewertung-knoepfe', [
|
||||
$knopf('bewertung-knopf', 'Zum Bewertungsformular', '/immobilienbewertung', 'weiss'),
|
||||
$knopf('bewertung-anrufen', $telefon, $tel, 'hell', [], 'phone'),
|
||||
], '14px', ['flexWrap' => 'wrap', 'margin' => ['top' => '22px']]),
|
||||
]),
|
||||
$column('bewertung-bild-spalte', [
|
||||
$bild('bewertung-bild', $medien['bewertung'], 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch', [
|
||||
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '480px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
|
||||
'mobile' => ['imageHeight' => '280px'],
|
||||
], null, '& img{display:block;width:100%;box-shadow:18px 18px 0 #0b6aa2}'),
|
||||
], ['desktop' => ['padding' => ['right' => '22px', 'bottom' => '22px']]]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '88px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '112px', 'bottom' => '112px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
], 'bewertung', "position:relative;overflow:hidden\n&::after{content:'';position:absolute;right:-120px;top:-120px;width:360px;height:360px;border:2px solid rgba(46,168,224,.18);border-radius:50%;pointer-events:none}"),
|
||||
|
||||
// 5 – Kontakt: Blau, große Telefonnummer, Knopf zur Kontaktseite.
|
||||
$section('kontakt-bereich', [
|
||||
$row('kontakt-zeile', [
|
||||
$column('kontakt-text', [
|
||||
$heading('kontakt-titel', 'Sprechen Sie mit uns.', 2, [...$h2Hell, 'desktop' => [...$h2Hell['desktop'], 'fontSize' => '40px', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
|
||||
$text('kontakt-satz', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Rufen Sie an oder schreiben Sie uns.', $fliess('#e3f1f9', '0px', '560px')),
|
||||
]),
|
||||
$zeile('kontakt-wege', [
|
||||
$knopf('kontakt-anrufen', $telefon, $tel, 'weiss', [], 'phone'),
|
||||
$knopf('kontakt-schreiben', 'Nachricht schreiben', '/kontakt', 'hell'),
|
||||
], '14px', ['flexWrap' => 'wrap', 'justifyContent' => 'flex-end'], ['justifyContent' => 'flex-start']),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '3 2'],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@blau', 'color' => '@papier', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '48px']],
|
||||
], 'kontakt'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Hero-Text steigt beim Laden auf; der Abschnittshintergrund ist das LCP-Element und bleibt ruhig.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-satz' => ['rise', 'medium'],
|
||||
'hero-knoepfe' => ['rise', 'medium'],
|
||||
'hero-fakten' => ['fade', 'long'],
|
||||
'leistungen-kopf' => ['rise'],
|
||||
'leistung-1' => ['rise'],
|
||||
'leistung-2' => ['rise', 'short'],
|
||||
'leistung-3' => ['rise', 'medium'],
|
||||
'suche-bild-spalte' => ['left'],
|
||||
'suche-text' => ['rise', 'short'],
|
||||
'bewertung-text' => ['rise'],
|
||||
'bewertung-bild-spalte' => ['right', 'short'],
|
||||
'kontakt-zeile' => ['rise'],
|
||||
]);
|
||||
|
||||
$homeId = $anlegen('home', $startseite, 'EBI Immobilien Karlsruhe – Immobilien kaufen, mieten und bewerten lassen', $medien['karlsruhe']['id']);
|
||||
|
||||
/** Der Seitenkopf der Unterseiten: Quartiersfoto, stark abgedunkelt, Kleinzeile, Titel, Vorspann. */
|
||||
$seitenkopf = static fn (string $praefix, string $klein, string $titel, string $vorspann) => $section($praefix.'-kopf', [
|
||||
$kleinzeile($praefix.'-klein', $klein, '@himmel'),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '54px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '18px']],
|
||||
'mobile' => ['fontSize' => '36px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.78,
|
||||
'color' => '@papier', 'padding' => ['top' => '88px', 'bottom' => '80px'], 'borderWidth' => $nurUnten('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
|
||||
'mobile' => ['padding' => ['top' => '52px', 'bottom' => '48px']],
|
||||
]);
|
||||
|
||||
// Das Formular in einer weißen Karte: Felder auf Sand, Fokus in Logo-Blau, Schrittanzeige lesbar.
|
||||
$formularCss = '& .block-form{max-width:none}
|
||||
& .form-field label{font-size:15px;font-weight:700;color:#14273a}
|
||||
& .form-field input{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
|
||||
& .form-field select{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
|
||||
& .form-field textarea{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:130px}
|
||||
& .form-field input:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .form-field select:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .form-field textarea:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .field-note{font-size:13px;color:#4d5966;opacity:1}
|
||||
& .form-step-intro{color:#4d5966;opacity:1}
|
||||
& .form-progress-step{color:#4d5966;opacity:1}
|
||||
& .form-choice{display:flex;flex-wrap:wrap;gap:10px}
|
||||
& .form-choice .check-row{gap:10px;align-items:center;background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:11px 16px;transition:border-color .2s,background-color .2s}
|
||||
& .form-choice .check-row:hover{border-color:#0b6aa2;background:#fff}
|
||||
& .check-row label{font-weight:500;font-size:15px;color:#14273a}
|
||||
& .check-row input{accent-color:#0b6aa2;min-height:0;width:18px;height:18px;padding:0;margin:0}
|
||||
& .cms-button{min-height:54px;font-size:16px;transition:transform .2s ease,background-color .2s ease}
|
||||
& .cms-button:hover{transform:translateY(-2px)}';
|
||||
|
||||
$karte = [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('44px'), 'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#14273a1f'],
|
||||
'mobile' => ['padding' => ['top' => '26px', 'right' => '20px', 'bottom' => '28px', 'left' => '20px']],
|
||||
];
|
||||
|
||||
/** Die Kontaktkarte für die Seitenspalte: Navy, Telefon und E-Mail. */
|
||||
$kontaktKarte = static fn (string $praefix) => $column($praefix.'-kontakt', [
|
||||
$heading($praefix.'-kontakt-titel', 'Lieber direkt sprechen?', 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '16px']]]),
|
||||
$zeile($praefix.'-kontakt-telefon', [
|
||||
$symbol($praefix.'-kontakt-telefon-icon', 'phone', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-telefon-text', '<a href="'.$tel.'">'.$telefon.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;font-weight:700}\n& a:hover{color:#7fd0f2}"),
|
||||
], '12px', ['margin' => ['bottom' => '10px']]),
|
||||
$zeile($praefix.'-kontakt-mail', [
|
||||
$symbol($praefix.'-kontakt-mail-icon', 'mail', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;overflow-wrap:anywhere}\n& a:hover{color:#7fd0f2}"),
|
||||
], '12px', ['margin' => ['bottom' => '10px']]),
|
||||
$zeile($praefix.'-kontakt-ort', [
|
||||
$symbol($praefix.'-kontakt-ort-icon', 'pin', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-ort-text', $strasse.', '.$plz.' '.$ort, ['desktop' => ['fontSize' => '15px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
|
||||
], '12px', ['alignItems' => 'flex-start']),
|
||||
], ['desktop' => ['backgroundColor' => '@tinte', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px')]]);
|
||||
|
||||
/** Eine Formularseite: Kopf, Formularkarte links, Seitenspalte mit Hinweisen und Kontakt rechts. */
|
||||
$formularseite = static function (string $praefix, string $klein, string $titel, string $vorspann, string $kartentitel, string $formular, array $seitenspalte) use ($seitenkopf, $section, $row, $column, $heading, $text, $karte, $formularCss, $kontaktKarte, $linkCss, $zweiSpalten): array {
|
||||
return [
|
||||
$seitenkopf($praefix, $klein, $titel, $vorspann),
|
||||
$section($praefix, [
|
||||
$row($praefix.'-zeile', [
|
||||
$column($praefix.'-karte', [
|
||||
$heading($praefix.'-karte-titel', $kartentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '28px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '24px']], 'mobile' => ['fontSize' => '24px']]),
|
||||
['id' => $praefix.'-formular', 'type' => 'form', 'props' => ['form' => $formular], 'css' => $formularCss],
|
||||
$text($praefix.'-hinweis', 'Ihre Angaben verwenden wir nur für Ihre Anfrage. Mehr dazu in der <a href="/datenschutz">Datenschutzerklärung</a>.', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']]], $linkCss),
|
||||
], $karte),
|
||||
$column($praefix.'-seite', [...$seitenspalte, $kontaktKarte($praefix)], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'gap' => '24px']]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '2 1'],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '64px', 'bottom' => '100px']],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'bottom' => '60px']],
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
/** Hinweiskarte der Seitenspalte: Titel und Haken. */
|
||||
$hinweis = static fn (string $id, string $titel, array $punkte) => $column($id, [
|
||||
$heading($id.'-titel', $titel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '18px']]]),
|
||||
...array_map(static fn (int $i, string $p) => $haken($id.'-punkt-'.$i, $p, '@text', '@blau'), array_keys($punkte), $punkte),
|
||||
], ['desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px'), 'borderWidth' => $nurOben('3px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau']]);
|
||||
|
||||
$anlegen('suchauftrag', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Suchauftrag',
|
||||
'description' => 'Ihre Traumimmobilie war noch nicht dabei? Erstellen Sie einen Suchauftrag bei EBI Immobilien in Karlsruhe – wir melden uns, sobald etwas Passendes dabei ist.',
|
||||
'blocks' => $formularseite('suchauftrag', 'Immobilien-Suchauftrag', 'Suchauftrag erstellen', 'Ihre Traumimmobilie haben Sie noch nicht gefunden? Wir unterstützen Sie gern bei Ihrer Suche – sagen Sie uns, was Sie sich wünschen.', 'Was suchen Sie?', 'suchauftrag', [
|
||||
$hinweis('suchauftrag-ablauf', 'So geht es weiter', [
|
||||
'Wir sehen uns Ihre Wünsche persönlich an.',
|
||||
'Passt ein Objekt, melden wir uns bei Ihnen.',
|
||||
'Eine Besichtigung vereinbaren wir direkt mit Ihnen.',
|
||||
]),
|
||||
]),
|
||||
], ['suchauftrag-karte' => ['rise'], 'suchauftrag-seite' => ['rise', 'short']]), 'Suchauftrag erstellen | '.$firma.' Karlsruhe');
|
||||
|
||||
$anlegen('immobilienbewertung', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
|
||||
'description' => 'Was ist Ihre Immobilie wert? EBI Immobilien bewertet Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Karlsruhe und Umgebung.',
|
||||
'blocks' => $formularseite('bewertung', 'Immobilienbewertung', 'Was ist Ihre Immobilie wert?', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Beantworten Sie dazu ein paar kurze Fragen.', 'Bewertung anfragen', 'bewertung', [
|
||||
$hinweis('bewertung-ablauf', 'Gut zu wissen', [
|
||||
'Drei kurze Schritte, ungefähre Angaben genügen.',
|
||||
'Für Verkauf und Vermietung.',
|
||||
'Wir melden uns persönlich mit einer Einschätzung.',
|
||||
]),
|
||||
]),
|
||||
], ['bewertung-karte' => ['rise'], 'bewertung-seite' => ['rise', 'short']]), 'Immobilienbewertung Karlsruhe | '.$firma);
|
||||
|
||||
$anlegen('kontakt', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kontakt',
|
||||
'description' => 'So erreichen Sie EBI Immobilien in Karlsruhe: Telefon, E-Mail und Kontaktformular.',
|
||||
'blocks' => $formularseite('kontakt', 'Kontakt', 'Kontaktieren Sie uns', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Wir freuen uns auf Ihre Nachricht.', 'Ihre Nachricht', 'kontakt', [
|
||||
$hinweis('kontakt-themen', 'Wobei wir helfen', [
|
||||
'Kauf und Miete von Wohnungen und Häusern',
|
||||
'<a href="/suchauftrag">Suchauftrag</a> für Ihre Wunschimmobilie',
|
||||
'<a href="/immobilienbewertung">Bewertung</a> für Verkauf oder Vermietung',
|
||||
]),
|
||||
]),
|
||||
], ['kontakt-karte' => ['rise'], 'kontakt-seite' => ['rise', 'short']]), 'Kontakt | '.$firma.' Karlsruhe');
|
||||
|
||||
/** Eine Rechtsseite: Seitenkopf, dann Absätze mit Zwischenüberschriften. */
|
||||
$rechtsseite = static function (string $praefix, string $titel, string $vorspann, array $absaetze) use ($seitenkopf, $section, $column, $heading, $text, $linkCss, $nurOben): array {
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
if ('' !== $zwischentitel) {
|
||||
$punkte[] = $heading($praefix.'-h2-'.$i, $zwischentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '23px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'color' => '@tinte', 'margin' => ['top' => '36px', 'bottom' => '10px']]]);
|
||||
}
|
||||
$punkte[] = $text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss);
|
||||
}
|
||||
|
||||
return [
|
||||
$seitenkopf($praefix, 'Rechtliches', $titel, $vorspann),
|
||||
$section($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '820px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '32px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '12px', 'bottom' => '64px']]]),
|
||||
];
|
||||
};
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben von EBI Immobilien, Hopfenstraße 10b, 76185 Karlsruhe.',
|
||||
'blocks' => $rechtsseite('impressum', 'Impressum', 'Angaben gemäß § 5 DDG.', [
|
||||
['Anbieter', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort],
|
||||
['Kontakt', 'Telefon: <a href="'.$tel.'">'.$telefon.'</a><br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a>'],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Haftung für Inhalte', 'Die Inhalte dieser Website wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehmen wir jedoch keine Gewähr. Angaben zu Immobilien sind unverbindlich; maßgeblich ist das jeweilige Exposé.'],
|
||||
['Haftung für Links', 'Diese Website enthält Verknüpfungen zu Websites Dritter. Für deren Inhalte ist der jeweilige Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverstößen entfernen wir solche Links umgehend.'],
|
||||
['Urheberrecht', 'Die Inhalte dieser Website unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede Verwertung außerhalb der Grenzen des Urheberrechts bedarf der vorherigen schriftlichen Zustimmung. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.'],
|
||||
['Bildnachweis', 'Fotos: Pixabay (Pixabay-Lizenz).'],
|
||||
]),
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Welche Daten beim Besuch der Website von EBI Immobilien und bei einer Anfrage verarbeitet werden.',
|
||||
'blocks' => $rechtsseite('datenschutz', 'Datenschutzerklärung', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortlicher', $firma.', '.$strasse.', '.$plz.' '.$ort.'. Telefon <a href="'.$tel.'">'.$telefon.'</a>, E-Mail <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet unser Hostingdienstleister technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO. Mit dem Hostingdienstleister besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'],
|
||||
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Suchauftrag, Bewertung und Kontaktformular', 'Wenn Sie uns über ein Formular schreiben, verarbeiten wir Ihre Angaben – etwa Name, E-Mail-Adresse, Telefonnummer, Anschrift und die Angaben zu Ihrer Wunschimmobilie oder Ihrer Immobilie –, um Ihre Anfrage zu bearbeiten und Ihnen passende Angebote zu machen (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
|
||||
['5. Kontakt per E-Mail oder Telefon', 'Wenn Sie uns schreiben oder anrufen, verarbeiten wir Ihre Angaben, um Ihr Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'],
|
||||
['6. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen.'],
|
||||
['7. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', 'Fragen zum Datenschutz beantworten wir gern unter <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
|
||||
]),
|
||||
], 'Datenschutz | '.$firma);
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$section('fehler', [
|
||||
$kleinzeile('fehler-label', 'Fehler 404'),
|
||||
$heading('fehler-titel', 'Diese Seite gibt es leider nicht.', 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '56px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '36px'],
|
||||
]),
|
||||
$text('fehler-text', 'Vielleicht wurde sie verschoben. Von der Startseite aus finden Sie alles Wichtige – oder Sie erstellen direkt einen Suchauftrag.', $fliess('@text', '32px', '520px')),
|
||||
$zeile('fehler-knoepfe', [
|
||||
$knopf('fehler-knopf', 'Zur Startseite', '/'),
|
||||
$knopf('fehler-suche', 'Suchauftrag erstellen', '/suchauftrag', 'rahmen', [], 'none'),
|
||||
], '14px', ['flexWrap' => 'wrap']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '112px', 'bottom' => '132px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// Alte WordPress-Adressen von ebi-immobilien.de.
|
||||
foreach ([
|
||||
'/contact-us' => '/kontakt',
|
||||
'/immobilienbewertungen' => '/immobilienbewertung',
|
||||
'/immobilien' => '/suchauftrag',
|
||||
'/property-search' => '/suchauftrag',
|
||||
] as $von => $nach) {
|
||||
$redirects->save($von, $nach, 301);
|
||||
}
|
||||
echo "Weiterleitungen angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menü, Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiteId = static fn (string $pfad): string => (string) $db->fetchOne('SELECT id FROM page WHERE path = ?', [$pfad]);
|
||||
$eintrag = static fn (string $label, string $pfad, array $extra = []): array => ['label' => $label, 'type' => 'page', 'pageId' => $seiteId($pfad), 'children' => [], ...$extra];
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $homeId, 'anchor' => 'leistungen', 'children' => []],
|
||||
$eintrag('Suchauftrag', 'suchauftrag'),
|
||||
$eintrag('Immobilienbewertung', 'immobilienbewertung'),
|
||||
$eintrag('Kontakt', 'kontakt'),
|
||||
// „Jetzt anrufen“ als Menü-Button (ADR 110); auf dem Telefon im aufgeklappten Menü (Umut, 13.10.2026).
|
||||
$eintrag('Jetzt anrufen', 'kontakt', [
|
||||
'type' => 'url', 'url' => $tel, 'pageId' => null,
|
||||
'icon' => 'phone', 'iconPosition' => 'before',
|
||||
'button' => [
|
||||
'id' => 'm'.substr(hash('sha256', 'ebi-immobilien-anrufen'), 0, 10), 'shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal',
|
||||
'fill' => '@blau', 'ink' => '@papier', 'hoverFill' => '@tinte', 'hoverInk' => '@papier',
|
||||
'mobile' => 'menu', 'iconMotion' => 'none',
|
||||
],
|
||||
]),
|
||||
], 0);
|
||||
echo "Menü angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'EBI Immobilien in Karlsruhe: Immobilien kaufen und mieten, Suchauftrag und Immobilienbewertung.',
|
||||
'email' => $mail, 'phone' => '+49 172 6110975',
|
||||
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
|
||||
'areaServed' => 'Karlsruhe',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['favicon']['id'],
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => true, 'postMetaHeader' => false,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
file_put_contents($root.'/var/ebi-immobilien-zugang.txt', "Verwaltung: Benutzer „umut“, Passwort: $passwort\n");
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
+589
-199
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Prüft, ob CMS und Zentrale dieselbe Sprache sprechen (ADR 123).
|
||||
*
|
||||
* Die Meldung kommt aus App\Ops\Zentrale, die Antwort aus Zentrale\Api - beide echt, nur ohne
|
||||
* Netz dazwischen: Registrierung mit Schlüssel, unterschriebene Folgemeldung, Auftrag abholen,
|
||||
* Ergebnis zurückmelden, fremde Unterschrift abweisen, zweite Installation mit fremder Kennung.
|
||||
*
|
||||
* Aufruf: php scripts/zentrale-check.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale as CmsZentrale;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
spl_autoload_register(static function (string $klasse): void {
|
||||
if (str_starts_with($klasse, 'Zentrale\\')) {
|
||||
require dirname(__DIR__).'/zentrale/app/'.substr($klasse, 9).'.php';
|
||||
}
|
||||
});
|
||||
|
||||
$tmp = str_replace('\\', '/', sys_get_temp_dir()).'/zentrale-check-'.bin2hex(random_bytes(4));
|
||||
mkdir($tmp.'/site/var', 0o775, true);
|
||||
file_put_contents($tmp.'/site/VERSION', "1.35.1\n");
|
||||
$pdo = Zentrale\Datenbank::oeffnen($tmp.'/zentrale.db');
|
||||
$api = new Zentrale\Api($pdo);
|
||||
|
||||
$fehler = 0;
|
||||
$pruefe = static function (bool $ok, string $was) use (&$fehler): void {
|
||||
echo ($ok ? 'ok ' : 'FEHLER ').$was."\n";
|
||||
$fehler += $ok ? 0 : 1;
|
||||
};
|
||||
|
||||
$ueberApi = static function (string $url, string $body, array $kopf) use ($api): array {
|
||||
$antwort = $api->melden($body, $kopf['X-KantSites-Signatur'] ?? '', '203.0.113.7');
|
||||
|
||||
return ['status' => $antwort['status'], 'body' => $antwort['body'], 'signatur' => (string) $antwort['signatur']];
|
||||
};
|
||||
|
||||
$installation = static function (string $wurzel, string $adresse, \Closure $senden): CmsZentrale {
|
||||
$db = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
$updater = new Updater($wurzel, 'sqlite:///:memory:', new CommandRunner(), $db);
|
||||
|
||||
return new CmsZentrale($wurzel, $updater, new ArchiveUpdate($wurzel, $updater, new Backup($wurzel, 'sqlite:///:memory:', $wurzel.'/var/media', $db, new CommandRunner()), new Download()), 'https://sites.example', $adresse, 'sqlite:///x', $senden);
|
||||
};
|
||||
|
||||
$site = $installation($tmp.'/site', 'https://kunde-eins.de', $ueberApi);
|
||||
$erste = $site->melden();
|
||||
$pruefe(null === $erste['auftrag'], 'Erste Meldung wird angenommen, kein Auftrag');
|
||||
$zeile = $pdo->query('SELECT * FROM installation')->fetch();
|
||||
$pruefe('kunde-eins.de' === ($zeile['domain'] ?? null) && '1.35.1' === $zeile['fassung'], 'Domain und Fassung stehen in der Zentrale');
|
||||
$ich = json_decode((string) file_get_contents($tmp.'/site/var/zentrale.json'), true);
|
||||
$pruefe(true === $ich['registriert'], 'Installation gilt danach als angemeldet');
|
||||
|
||||
$bestand = new Zentrale\Bestand($pdo);
|
||||
$pruefe(null === $bestand->beauftragen($zeile['id'], '1.36.0'), 'Update auf 1.36.0 lässt sich beauftragen');
|
||||
$zweite = $site->melden();
|
||||
$pruefe('1.36.0' === ($zweite['auftrag']['fassung'] ?? null) && 'update' === $zweite['auftrag']['aktion'], 'Folgemeldung holt den Auftrag ab');
|
||||
$pruefe(null === $site->melden()['auftrag'], 'Ein abgeholter Auftrag kommt nicht zweimal');
|
||||
$pruefe(null !== $bestand->beauftragen($zeile['id'], '1.36.0'), 'Während ein Auftrag läuft, gibt es keinen zweiten');
|
||||
|
||||
$site->auftragAngenommen($zweite['auftrag']['id'], '1.36.0');
|
||||
file_put_contents($tmp.'/site/VERSION', "1.36.0\n");
|
||||
$site->auftragErledigt(true, 'Fassung 1.36.0 läuft.');
|
||||
$site->melden();
|
||||
$auftrag = $pdo->query('SELECT * FROM auftrag')->fetch();
|
||||
$pruefe(1 === (int) $auftrag['ok'] && null !== $auftrag['erledigt'], 'Ergebnis kommt in der Zentrale an');
|
||||
$pruefe('1.36.0' === $pdo->query('SELECT fassung FROM installation')->fetchColumn(), 'Neue Fassung ist gemeldet');
|
||||
$pruefe(null === $site->offenerAuftrag(), 'Die Installation vergisst den quittierten Auftrag');
|
||||
|
||||
// Eine fremde Antwort: anderer Schlüssel unterschreibt.
|
||||
$gefaelscht = $installation($tmp.'/site', 'https://kunde-eins.de', static function (string $url, string $body) use ($ueberApi): array {
|
||||
$echt = $ueberApi($url, $body, []);
|
||||
|
||||
return [...$echt, 'signatur' => hash_hmac('sha256', $echt['body'], str_repeat('b', 64))];
|
||||
});
|
||||
try {
|
||||
$gefaelscht->melden();
|
||||
$pruefe(false, 'Unsignierte Meldung wird abgewiesen');
|
||||
} catch (\RuntimeException $e) {
|
||||
$pruefe(str_contains($e->getMessage(), '403'), 'Meldung ohne gültige Unterschrift weist die Zentrale mit 403 ab');
|
||||
}
|
||||
|
||||
// Jemand übernimmt die Kennung mit eigenem Schlüssel.
|
||||
$body = json_encode(['id' => $zeile['id'], 'adresse' => 'https://fremd.de', 'fassung' => '1.36.0', 'schluessel' => str_repeat('c', 64)]);
|
||||
$fremd = $api->melden($body, hash_hmac('sha256', $body, str_repeat('c', 64)), '198.51.100.1');
|
||||
$pruefe(403 === $fremd['status'], 'Fremder Schlüssel für eine bekannte Kennung wird abgewiesen');
|
||||
|
||||
// Die Zentrale hat die Installation vergessen: Sie meldet sich mit Schlüssel neu an.
|
||||
$pdo->exec('DELETE FROM installation');
|
||||
$site->melden();
|
||||
$pruefe(1 === (int) $pdo->query('SELECT COUNT(*) FROM installation')->fetchColumn(), 'Nach Verlust in der Zentrale meldet sich die Installation neu an');
|
||||
|
||||
// Umzug: dieselbe Kennung, andere Domain.
|
||||
$umzug = $installation($tmp.'/site', 'https://www.kunde-zwei.de', $ueberApi);
|
||||
$umzug->melden();
|
||||
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
|
||||
$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar');
|
||||
|
||||
echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
|
||||
exit($fehler > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,15 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
|
||||
return '';
|
||||
}
|
||||
// Gespeichert wurde nur, was beim Speichern galt. Ist eine Regel inzwischen strenger
|
||||
// (ADR 116), fällt das CSS dieses einen Blocks weg - die Seite selbst bleibt erreichbar.
|
||||
try {
|
||||
$rules = $this->parse($css);
|
||||
} catch (InvalidDocument) {
|
||||
return '';
|
||||
}
|
||||
$out = '';
|
||||
foreach ($this->parse($css) as $suffix => $declarations) {
|
||||
foreach ($rules as $suffix => $declarations) {
|
||||
if ('' === $declarations) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +200,13 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
// Geschwister des Elements selbst (`& ~ *`, `& + p`) liegen außerhalb des Blocks - damit
|
||||
// ließe sich die ganze Seite umgestalten. Innerhalb des Blocks bleiben `~` und `+`
|
||||
// erlaubt. Und Anführungszeichen paarweise: ein offener String verschluckte sonst
|
||||
// alle folgenden Regeln der Seite (ADR 116).
|
||||
if (1 === preg_match('/^\s*[~+]/', $suffix) || 0 !== substr_count($suffix, '"') % 2 || 0 !== substr_count($suffix, "'") % 2) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ reicht über den Block hinaus und ist deshalb nicht erlaubt.', $suffix));
|
||||
}
|
||||
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
||||
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
||||
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
||||
@@ -247,7 +261,8 @@ final class CustomCode
|
||||
$out[] = $property.':'.$value;
|
||||
continue;
|
||||
}
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
|
||||
// Kein Kommentaranfang und -ende: ein offener Kommentar verschluckte die Regeln danach.
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value) || str_contains($value, '/*') || str_contains($value, '*/')) {
|
||||
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
|
||||
}
|
||||
$out[] = $property.':'.$value;
|
||||
|
||||
@@ -16,7 +16,7 @@ final class DocumentValidator
|
||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
|
||||
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
|
||||
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
|
||||
@@ -46,6 +46,47 @@ final class FormRepository
|
||||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||||
public const MIN_SECONDS = 3;
|
||||
public const MAX_SECONDS = 86400;
|
||||
/**
|
||||
* Gespeicherte Anfragen je Formular und insgesamt je Stunde (ADR 116). Darüber hinaus wird
|
||||
* nichts mehr gespeichert und nichts verschickt - eine Flut füllt sonst Postfach und
|
||||
* Datenbank, und die Grenze je Absender hilft nicht gegen viele Absender.
|
||||
*/
|
||||
public const FORM_RATE_LIMIT = 30;
|
||||
public const GLOBAL_RATE_LIMIT = 120;
|
||||
|
||||
/**
|
||||
* Das Siegel eines Formulars: gebunden an das Formular und an den Zweck, damit ein von einer
|
||||
* Seite geholtes Siegel nicht für jedes Formular gilt (ADR 116).
|
||||
*/
|
||||
public static function stamp(string $slug, int $issued, string $secret): string
|
||||
{
|
||||
return hash_hmac('sha256', 'formular|'.$slug.'|'.$issued, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer ein Absender ist, für die Grenze je Stunde. Bei IPv6 zählt das /64-Netz: ein einzelner
|
||||
* Anschluss hat davon Milliarden Adressen, mit der vollen Adresse wäre die Grenze wirkungslos.
|
||||
*/
|
||||
public static function senderKey(?string $ip, string $secret): string
|
||||
{
|
||||
$ip = (string) $ip;
|
||||
if (str_contains($ip, ':') && false !== ($binary = @inet_pton($ip)) && 16 === \strlen($binary)) {
|
||||
$ip = bin2hex(substr($binary, 0, 8)).'::/64';
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', 'absender|'.$ip, $secret);
|
||||
}
|
||||
|
||||
/** Ob für dieses Formular oder insgesamt in der letzten Stunde schon zu viel ankam. */
|
||||
public function flooded(string $formId): bool
|
||||
{
|
||||
$since = gmdate('Y-m-d H:i:s', time() - 3600);
|
||||
if (self::FORM_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE form_id = ? AND created_at >= ?', [$formId, $since])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::GLOBAL_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE created_at >= ?', [$since]);
|
||||
}
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
|
||||
@@ -169,6 +169,27 @@ final class PageRepository
|
||||
*
|
||||
* @return int the number of pages moved into the bin
|
||||
*/
|
||||
/**
|
||||
* Ob die Seite - mit $subtree auch eine ihrer Unterseiten - öffentlich ist. Was eine solche
|
||||
* Seite live verändert (Adresse, Papierkorb, Bereiche, Beitragsdaten), darf nur, wer auch
|
||||
* veröffentlichen darf (ADR 116).
|
||||
*/
|
||||
public function isLive(string $pageId, bool $subtree = false): bool
|
||||
{
|
||||
$page = $this->db->fetchAssociative('SELECT path, locale, published_revision_id FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$pageId, 'default']);
|
||||
if (false === $page) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $page['published_revision_id'] || !$subtree) {
|
||||
return null !== $page['published_revision_id'];
|
||||
}
|
||||
|
||||
return false !== $this->db->fetchOne(
|
||||
'SELECT id FROM page WHERE website_id = ? AND locale = ? AND path LIKE ? AND deleted_at IS NULL AND published_revision_id IS NOT NULL LIMIT 1',
|
||||
['default', $page['locale'], $page['path'].'/%'],
|
||||
);
|
||||
}
|
||||
|
||||
public function trashPage(string $pageId, int $expectedVersion): int
|
||||
{
|
||||
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
|
||||
@@ -279,11 +300,14 @@ final class PageRepository
|
||||
{
|
||||
$published = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
|
||||
// Online geht die Fassung, die beim Planen vorlag (ADR 116) - nicht ein Entwurf, den
|
||||
// danach jemand ohne Recht zum Veröffentlichen gespeichert hat. Planungen aus der Zeit
|
||||
// vor dieser Spalte tragen keine Fassung; für sie bleibt es beim Entwurf.
|
||||
'SELECT id, path, COALESCE(publish_revision_id, draft_revision_id) AS revision, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND COALESCE(publish_revision_id, draft_revision_id) IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> COALESCE(publish_revision_id, draft_revision_id))',
|
||||
['default', gmdate('Y-m-d H:i:s')],
|
||||
) as $row) {
|
||||
try {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
$this->publish($row['id'], $row['revision'], (int) $row['version']);
|
||||
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
|
||||
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
|
||||
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
|
||||
@@ -291,7 +315,7 @@ final class PageRepository
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL WHERE id = ?', [$row['id']]);
|
||||
$published[] = $row['path'];
|
||||
}
|
||||
|
||||
@@ -304,10 +328,13 @@ final class PageRepository
|
||||
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
|
||||
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
|
||||
}
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
|
||||
);
|
||||
// Mit dem Termin wird der jetzige Entwurf festgehalten: genau er geht online.
|
||||
$changed = '' === $when
|
||||
? $this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL, version = version + 1 WHERE id = ? AND version = ?', [$pageId, $expectedVersion])
|
||||
: $this->db->executeStatement(
|
||||
'UPDATE page SET publish_at = ?, publish_revision_id = draft_revision_id, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ final class ConsentController extends AbstractController
|
||||
'none' => [],
|
||||
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
|
||||
};
|
||||
$response = $this->redirect($this->backTo($request));
|
||||
// consent.js schickt dasselbe Formular im Hintergrund und kennzeichnet das mit dieser
|
||||
// Kopfzeile: dann keine Weiterleitung, nur das Cookie - die Seite bleibt stehen und das
|
||||
// Skript tauscht aus, was jetzt geladen werden darf (ADR 115). Ein fremdes Formular kann
|
||||
// die Kopfzeile nicht setzen; ein fremdes Skript bräuchte dafür eine CORS-Freigabe.
|
||||
$response = '1' === $request->headers->get('X-Kant-Consent') ? new Response(status: Response::HTTP_NO_CONTENT) : $this->redirect($this->backTo($request));
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
|
||||
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
|
||||
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
|
||||
@@ -97,7 +102,9 @@ final class ConsentController extends AbstractController
|
||||
$target = $request->request->getString('back');
|
||||
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
|
||||
// lesen Browser genauso.
|
||||
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
|
||||
// Steuerzeichen und Leerraum ebenfalls nicht: Browser entfernen Tabulator und Zeilenumbruch
|
||||
// aus Adressen, aus `/<Tab>/fremd.example` würde so wieder `//fremd.example`.
|
||||
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target) || 1 === preg_match('/[\x00-\x20\x7F\\\\]/', $target)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||||
return $blocked;
|
||||
}
|
||||
$terms = static function (string $raw): array {
|
||||
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
|
||||
|
||||
@@ -242,6 +245,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||||
return $blocked;
|
||||
}
|
||||
// An empty field means "the standard area"; that is stored as null, not as an address.
|
||||
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
|
||||
try {
|
||||
@@ -265,6 +271,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$moved = $this->pages->movePage(
|
||||
$id,
|
||||
@@ -282,6 +291,22 @@ final class EditorController extends AbstractController
|
||||
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
|
||||
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
|
||||
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
|
||||
*/
|
||||
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
|
||||
{
|
||||
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->back($subtree
|
||||
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
|
||||
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
|
||||
public function schedule(Request $request, string $id): Response
|
||||
{
|
||||
@@ -297,7 +322,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
: 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
@@ -357,6 +382,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
|
||||
@@ -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,
|
||||
@@ -46,12 +45,12 @@ final class FormController extends AbstractController
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
}
|
||||
$senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret);
|
||||
$senderHash = FormRepository::senderKey($request->getClientIp(), $this->secret);
|
||||
|
||||
// Three quiet checks before the real validation. A caught bot is answered like a success
|
||||
// so it learns nothing, but nothing is stored and nothing is sent.
|
||||
$silentlyDropped = '' !== trim($request->request->getString('website'))
|
||||
|| !$this->timingIsPlausible($request)
|
||||
|| !$this->timingIsPlausible($request, $slug)
|
||||
|| $this->forms->rateLimited($senderHash)
|
||||
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
|
||||
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
|
||||
@@ -72,6 +71,11 @@ final class FormController extends AbstractController
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
// Kommt gerade für dieses Formular oder insgesamt zu viel an, wird nichts mehr gespeichert -
|
||||
// aber sichtbar, nicht still: ein echter Besucher soll wissen, dass er anrufen muss.
|
||||
if (!$silentlyDropped && $this->forms->flooded($form['id'])) {
|
||||
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
if ($this->deliver($form, $checked['values'])) {
|
||||
@@ -86,10 +90,10 @@ final class FormController extends AbstractController
|
||||
}
|
||||
|
||||
/** The signed timestamp makes the age of the form checkable without storing anything. */
|
||||
private function timingIsPlausible(Request $request): bool
|
||||
private function timingIsPlausible(Request $request, string $slug): bool
|
||||
{
|
||||
$issued = $request->request->getInt('issued');
|
||||
if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) {
|
||||
if (!hash_equals(FormRepository::stamp($slug, $issued, $this->secret), $request->request->getString('issuedSignature'))) {
|
||||
return false;
|
||||
}
|
||||
$age = time() - $issued;
|
||||
@@ -122,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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Controller;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Installation;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -20,6 +22,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*
|
||||
* After the first account exists, every route in this file answers 404 - not because a file was
|
||||
* deleted, but because the database says there is an installation.
|
||||
*
|
||||
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
|
||||
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
|
||||
* ein Cookie mit einem Nachweis darüber, nur für /install.
|
||||
*/
|
||||
final class InstallController extends AbstractController
|
||||
{
|
||||
@@ -27,10 +33,46 @@ final class InstallController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(): Response
|
||||
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
|
||||
public const KEY_COOKIE = 'kant_install';
|
||||
|
||||
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
|
||||
public function key(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($this->installation->isInstalled()) {
|
||||
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
||||
}
|
||||
$error = null;
|
||||
try {
|
||||
$this->installation->installKey();
|
||||
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
|
||||
if ('' !== $given) {
|
||||
if ($this->installation->acceptsKey($given)) {
|
||||
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
|
||||
// einem Serverprotokoll als Verweis stehen bleiben.
|
||||
$response = $this->redirectToRoute('install');
|
||||
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
|
||||
|
||||
return $response;
|
||||
}
|
||||
$error = 'Dieser Schlüssel passt nicht.';
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
|
||||
$response->headers->set('Referrer-Policy', 'no-referrer');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(Request $request): Response
|
||||
{
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
$checks = $this->installation->checks();
|
||||
|
||||
return $this->render('install/check.html.twig', [
|
||||
@@ -43,7 +85,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function database(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
$error = null;
|
||||
$driver = $request->request->getString('driver', 'sqlite');
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
|
||||
@@ -89,7 +133,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function migration(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->environmentWritten()) {
|
||||
return $this->redirectToRoute('install_database');
|
||||
}
|
||||
@@ -118,7 +164,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function website(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->schemaReady()) {
|
||||
return $this->redirectToRoute('install_migration');
|
||||
}
|
||||
@@ -150,7 +198,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function account(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->siteReady()) {
|
||||
return $this->redirectToRoute('install_website');
|
||||
}
|
||||
@@ -192,11 +242,21 @@ final class InstallController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
private function guard(): void
|
||||
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
|
||||
private function guard(Request $request): ?Response
|
||||
{
|
||||
if ($this->installation->isInstalled()) {
|
||||
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
||||
}
|
||||
try {
|
||||
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
|
||||
return null;
|
||||
}
|
||||
} catch (\RuntimeException) {
|
||||
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->generateUrl('install_key'));
|
||||
}
|
||||
|
||||
private function token(string $step): array
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\MailCheck;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* E-Mail-Versand (ADR 121, 122): eigene Seite unter „System“, damit die Systemseite nicht alles
|
||||
* trägt. Weg wählen, ausprobieren, alle Wege des Servers testen, speichern - und die letzten
|
||||
* Versuche mit Ursache.
|
||||
*/
|
||||
final class MailController extends AbstractController
|
||||
{
|
||||
public function __construct(private MailCheck $mail, private string $projectDir, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$session = $request->getSession();
|
||||
|
||||
return $this->render('admin/mail.html.twig', [
|
||||
'mail' => $this->mailFacts(),
|
||||
'mailResult' => $session->remove('mail_result'),
|
||||
'mailResults' => $session->remove('mail_results'),
|
||||
'mailNotice' => $session->remove('mail_notice'),
|
||||
// Die letzte Eingabe bleibt stehen, ohne Passwort.
|
||||
'mailForm' => $session->remove('mail_form'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versand einstellen und ausprobieren (ADR 121, 122): ein Weg testen, alle Wege des Servers
|
||||
* nacheinander testen, oder den gewählten Weg als MAILER_DSN in .env speichern.
|
||||
*
|
||||
* Das SMTP-Passwort steht nie im Formular. Bleibt das Feld leer, gilt das gespeicherte, solange
|
||||
* Server und Benutzer gleich bleiben.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$session = $request->getSession();
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$eingabe = [
|
||||
'weg' => $request->request->getString('weg'),
|
||||
'server' => trim($request->request->getString('server')),
|
||||
'port' => $request->request->getInt('port', 587),
|
||||
'benutzer' => trim($request->request->getString('benutzer')),
|
||||
'an' => trim($request->request->getString('to')),
|
||||
'von' => trim($request->request->getString('from')),
|
||||
];
|
||||
$session->set('mail_form', $eingabe);
|
||||
$aktion = $request->request->getString('aktion');
|
||||
|
||||
if ('alle' === $aktion) {
|
||||
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
|
||||
return $this->backToMail($request, $fehler);
|
||||
}
|
||||
$wege = array_map(static fn (array $weg): string => $weg['dsn'], MailCheck::serverWege());
|
||||
if ('' !== $eingabe['server']) {
|
||||
try {
|
||||
$wege['smtp'] = $this->smtpDsn($request, $eingabe);
|
||||
} catch (\InvalidArgumentException) {
|
||||
// Unvollständiges SMTP bleibt bei „alle“ einfach draußen.
|
||||
}
|
||||
}
|
||||
$ergebnisse = [];
|
||||
foreach ($wege as $schluessel => $dsn) {
|
||||
$ergebnisse[] = ['schluessel' => $schluessel] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail (alle Wege)', $dsn);
|
||||
}
|
||||
$session->set('mail_results', $ergebnisse);
|
||||
|
||||
return $this->backToMail($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$dsn = isset(MailCheck::serverWege()[$eingabe['weg']]) ? MailCheck::serverWege()[$eingabe['weg']]['dsn'] : $this->smtpDsn($request, $eingabe);
|
||||
} catch (\InvalidArgumentException $fehler) {
|
||||
return $this->backToMail($request, $fehler->getMessage());
|
||||
}
|
||||
|
||||
if ('speichern' === $aktion) {
|
||||
if ('.env' !== $this->environmentSource('MAILER_DSN')) {
|
||||
return $this->backToMail($request, 'MAILER_DSN ist in der Server-Umgebung gesetzt und hat dort Vorrang. Ändern lässt sie sich nur beim Hoster.');
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['MAILER_DSN' => $dsn]);
|
||||
} catch (\RuntimeException $fehler) {
|
||||
return $this->backToMail($request, $fehler->getMessage());
|
||||
}
|
||||
$weg = $this->mail->route($dsn);
|
||||
|
||||
return $this->backToMail($request, sprintf('Gespeichert: %s%s. Formulare versenden ab jetzt auf diesem Weg.', $weg['art'], '' !== $weg['ziel'] ? ' über '.$weg['ziel'] : ''), 'ok');
|
||||
}
|
||||
|
||||
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
|
||||
return $this->backToMail($request, $fehler);
|
||||
}
|
||||
$session->set('mail_result', ['an' => $eingabe['an']] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail', $dsn));
|
||||
|
||||
return $this->backToMail($request);
|
||||
}
|
||||
|
||||
/** „Letzte Versuche“ leeren: var/log/mail.log und mail.log.1. */
|
||||
#[Route('/%kant.admin_path%/system/e-mail/protokoll', name: 'admin_mail_clear', methods: ['POST'])]
|
||||
public function clear(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->mail->clearLog();
|
||||
|
||||
return $this->backToMail($request, 'Das Protokoll ist leer.', 'ok');
|
||||
}
|
||||
|
||||
/** @param array{an: string, von: string} $eingabe */
|
||||
private function mailAdressFehler(array $eingabe): ?string
|
||||
{
|
||||
foreach ([$eingabe['an'], $eingabe['von'] ?: $eingabe['an']] as $adresse) {
|
||||
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
|
||||
return '' === $adresse ? 'Für eine Testmail bitte eine Empfängeradresse eintragen.' : sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array{server: string, port: int, benutzer: string} $eingabe */
|
||||
private function smtpDsn(Request $request, array $eingabe): string
|
||||
{
|
||||
if ('' === $eingabe['server']) {
|
||||
throw new \InvalidArgumentException('Für SMTP bitte den Server eintragen, etwa smtp.ionos.de.');
|
||||
}
|
||||
$passwort = $request->request->getString('passwort');
|
||||
if ('' === $passwort) {
|
||||
$passwort = $this->mail->gespeichertesPasswort($eingabe['server'], $eingabe['benutzer']);
|
||||
}
|
||||
if ('' !== $eingabe['benutzer'] && '' === $passwort) {
|
||||
throw new \InvalidArgumentException('Für SMTP bitte das Passwort des Postfachs eintragen.');
|
||||
}
|
||||
|
||||
return MailCheck::smtpDsn($eingabe['server'], $eingabe['port'], $eingabe['benutzer'], $passwort);
|
||||
}
|
||||
|
||||
/** @param array{an: string, von: string} $eingabe */
|
||||
private function testEmail(Request $request, array $eingabe): Email
|
||||
{
|
||||
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
|
||||
|
||||
return (new Email())
|
||||
->from(new Address($eingabe['von'] ?: $eingabe['an']))
|
||||
->to(new Address($eingabe['an']))
|
||||
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
|
||||
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).\n\nWenn sie angekommen ist, funktioniert der Versand der Formulare auf diesem Weg.\n");
|
||||
}
|
||||
|
||||
private function backToMail(Request $request, ?string $meldung = null, string $art = 'error'): Response
|
||||
{
|
||||
if (null !== $meldung) {
|
||||
$request->getSession()->set('mail_notice', ['art' => $art, 'text' => $meldung]);
|
||||
}
|
||||
|
||||
return $this->redirect($this->generateUrl('admin_mail'));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function mailFacts(): array
|
||||
{
|
||||
$route = $this->mail->route();
|
||||
|
||||
return $route + [
|
||||
'einstellung' => $this->mail->einstellung(),
|
||||
'wege' => MailCheck::serverWege(),
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit server values take precedence over the single installation file. */
|
||||
private function environmentSource(string $name): string
|
||||
{
|
||||
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
|
||||
|
||||
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -371,12 +378,18 @@ final class PageController extends AbstractController
|
||||
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
|
||||
$request->attributes->set('kant_alternates', $alternates);
|
||||
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
|
||||
// Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt
|
||||
// consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das
|
||||
// Formular mit Weiterleitung.
|
||||
$embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
$needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request);
|
||||
$response = $this->render('page.html.twig', [
|
||||
'needsConsent' => $needsConsent,
|
||||
'page' => $page,
|
||||
'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(),
|
||||
@@ -429,8 +442,8 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $embeds['hosts'];
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
@@ -453,9 +466,9 @@ final class PageController extends AbstractController
|
||||
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
|
||||
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
|
||||
*
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
* @param list<string> $frameHosts Hosts of the embeds on this page, also of those still
|
||||
* waiting for consent (ADR 115: consent.js inserts them
|
||||
* without a reload, and a policy cannot change afterwards)
|
||||
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
|
||||
@@ -526,7 +539,7 @@ final class PageController extends AbstractController
|
||||
'errors' => [], 'values' => [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
'issuedSignature' => FormRepository::stamp($slug, $issued, $this->secret),
|
||||
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
|
||||
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
|
||||
];
|
||||
@@ -768,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 {
|
||||
@@ -1079,23 +1136,27 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts of embeds that may load on this request.
|
||||
* The hosts of all embeds on this page, and whether one of them still waits for consent.
|
||||
*
|
||||
* An embed whose group is not granted contributes nothing: its host must not appear in the
|
||||
* policy, because the frame is not rendered either.
|
||||
* Seit ADR 115 steht der Host auch dann in frame-src, wenn die Gruppe noch nicht erlaubt ist:
|
||||
* Die Richtlinie einer Seite steht beim Laden fest, und consent.js setzt den Rahmen nach der
|
||||
* Einwilligung ohne Neuladen ein. Die Erlaubnis lädt nichts; die Adresse des Inhalts steht
|
||||
* weiterhin erst nach der Einwilligung im HTML.
|
||||
*
|
||||
* @return list<string>
|
||||
* @return array{hosts: list<string>, blocked: bool}
|
||||
*/
|
||||
private function frameHosts(Request $request, array ...$documents): array
|
||||
private function embedState(Request $request, array ...$documents): array
|
||||
{
|
||||
$hosts = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
|
||||
$blocked = false;
|
||||
$walk = function (array $blocks) use (&$walk, &$hosts, &$blocked, $request): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
|
||||
if ('embed' === ($block['type'] ?? null) && '' !== ($block['props']['url'] ?? '')) {
|
||||
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
|
||||
if (is_string($host)) {
|
||||
$hosts[$host] = true;
|
||||
}
|
||||
$blocked = $blocked || !$this->consent->granted($request, $block['props']['group'] ?? '');
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
@@ -1104,7 +1165,7 @@ final class PageController extends AbstractController
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return array_keys($hosts);
|
||||
return ['hosts' => array_keys($hosts), 'blocked' => $blocked];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,7 +77,7 @@ final class PartialController extends AbstractController
|
||||
$request->request->getString('slug') ?: $this->slugify($name),
|
||||
$name,
|
||||
// A shared area starts with one empty section, so there is something to click on.
|
||||
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
||||
['schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
||||
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
|
||||
]],
|
||||
);
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\Installation;
|
||||
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\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,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 Funktionspruefung $pruefung, private SitePackage $packages, private HttpsRedirect $https)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +47,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 +66,9 @@ 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -264,28 +270,10 @@ final class SystemController extends AbstractController
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefen(...));
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
|
||||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite
|
||||
* als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut.
|
||||
*/
|
||||
private function pruefen(): ?string
|
||||
{
|
||||
$home = $this->settings->homePath();
|
||||
if (null === $this->pages->findPublished($home)) {
|
||||
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
|
||||
}
|
||||
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
|
||||
if ($antwort->getStatusCode() >= 400) {
|
||||
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wie sendAndStop, nur als JSON für die Systemseite. */
|
||||
private function sendJsonAndStop(array $daten): never
|
||||
{
|
||||
@@ -490,6 +478,28 @@ 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');
|
||||
}
|
||||
|
||||
/** 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 +507,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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Protection;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
@@ -36,6 +37,8 @@ final class InstallSubscriber implements EventSubscriberInterface
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
// Fehlende Sperrdateien nachtragen (var/, vendor/, Projektstamm) - einmal je Prozess.
|
||||
Protection::ensure($this->projectDir);
|
||||
if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|kantsites-origin$)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Schließt ein Update ab, das die Zentrale angestoßen hat (ADR 123).
|
||||
*
|
||||
* Aus der Verwaltung ruft die Systemseite den zweiten Schritt selbst auf. Bei einem Auftrag der
|
||||
* Zentrale sitzt niemand davor. Dann übernimmt die nächste Anfrage: Sie startet mit dem neuen
|
||||
* Code - genau das, was die Migrationen brauchen -, führt sie aus, prüft die Startseite und gibt
|
||||
* die Website frei oder schaltet zurück. Der Besucher, der sie auslöst, wartet so lange und
|
||||
* bekommt danach die Seite in der neuen Fassung.
|
||||
*
|
||||
* Vor der Wartungsseite (Priorität 40), weil die sonst jede Anfrage mit 503 beendet. Nur bei
|
||||
* einer Marke mit `fern`: Ein Update aus der Verwaltung schließt die Verwaltung ab.
|
||||
*
|
||||
* Der Prozess, der die Dateien getauscht hat, kommt hier nicht mehr vorbei: Seine Anfrage ist zu
|
||||
* dem Zeitpunkt längst über diesen Punkt hinaus.
|
||||
*/
|
||||
final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ArchiveUpdate $archive,
|
||||
private Installation $installation,
|
||||
private Updater $updater,
|
||||
private Funktionspruefung $pruefung,
|
||||
private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::REQUEST => ['onRequest', 48]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
// Der übliche Fall kostet eine Dateiprüfung.
|
||||
if (!$event->isMainRequest() || !is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) {
|
||||
return;
|
||||
}
|
||||
$offen = $this->archive->pending();
|
||||
if (true !== ($offen['fern'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
// Gleichzeitige Anfragen: Eine schließt ab, die anderen sehen die Wartungsseite.
|
||||
$sperre = $this->updater->lock();
|
||||
if (null === $sperre) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (true !== ($this->archive->pending()['fern'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$this->archive->finish($this->installation, $this->pruefung);
|
||||
} catch (\Throwable) {
|
||||
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
|
||||
} finally {
|
||||
flock($sperre, LOCK_UN);
|
||||
fclose($sperre);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,12 @@ final class ArchiveUpdate
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$ziel = $version ?? $neueste['version'];
|
||||
// Die Fassung landet in Adresse und Pfaden; nur die Form 1.2.3 ist zulässig (ADR 116).
|
||||
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $ziel)) {
|
||||
$melden($schritte, 'Fassung ermitteln', false, 'Die gewünschte Fassung hat nicht die Form 1.2.3.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
|
||||
if (version_compare($ziel, $this->updater->version(), '<=')) {
|
||||
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
|
||||
@@ -309,6 +315,21 @@ final class ArchiveUpdate
|
||||
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzeichnet den offenen zweiten Schritt als von der Zentrale angestoßen (ADR 123).
|
||||
*
|
||||
* Dann wartet niemand in der Verwaltung, der ihn aufruft; die nächste Anfrage mit dem neuen
|
||||
* Code schließt ihn ab (@see RemoteUpdateFinishSubscriber).
|
||||
*/
|
||||
public function markiereFern(): void
|
||||
{
|
||||
$offen = $this->pending();
|
||||
if (null === $offen) {
|
||||
return;
|
||||
}
|
||||
@file_put_contents($this->root.'/'.self::MARKE, json_encode([...$offen, 'fern' => true], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
||||
*
|
||||
@@ -560,15 +581,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,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite als
|
||||
* Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht.
|
||||
*
|
||||
* Der Abschluss eines Updates ruft sie nach den Migrationen auf, aus der Verwaltung und nach
|
||||
* einem Auftrag der Zentrale. Sie braucht eine laufende Hauptanfrage: Die Unteranfrage hängt sich
|
||||
* an deren Request-Stack.
|
||||
*/
|
||||
class Funktionspruefung
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private SiteSettings $settings, private HttpKernelInterface $kernel)
|
||||
{
|
||||
}
|
||||
|
||||
/** null heißt: alles gut. Sonst der Grund, warum die Fassung nicht läuft. */
|
||||
public function __invoke(): ?string
|
||||
{
|
||||
$home = $this->settings->homePath();
|
||||
if (null === $this->pages->findPublished($home)) {
|
||||
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
|
||||
}
|
||||
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
|
||||
if ($antwort->getStatusCode() >= 400) {
|
||||
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
+76
-12
@@ -37,6 +37,14 @@ final class Installation
|
||||
/** Longer than a person needs for a form, short enough that a stale tab stops working. */
|
||||
public const TOKEN_LIFETIME = 7200;
|
||||
|
||||
/**
|
||||
* Der Installationsschlüssel (ADR 116). Ohne ihn öffnet sich kein Schritt des Installers: wer
|
||||
* eine frisch entpackte Installation zuerst aufruft, soll sie nicht übernehmen können.
|
||||
* install.php legt ihn an und zeigt ihn nur dem Browser, der entpackt hat; wer per FTP
|
||||
* entpackt, findet ihn in dieser Datei. Mit dem ersten Konto wird er gelöscht.
|
||||
*/
|
||||
public const KEY_FILE = 'var/install.key';
|
||||
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private PageRepository $pages,
|
||||
@@ -357,16 +365,73 @@ final class Installation
|
||||
if (!hash_equals($password, $repeated)) {
|
||||
throw new InvalidPage('Die beiden Passwörter stimmen nicht überein.');
|
||||
}
|
||||
if ($this->isInstalled()) {
|
||||
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
|
||||
}
|
||||
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => $user->id,
|
||||
'username' => $username,
|
||||
'password_hash' => $this->hasher->hashPassword($user, $password),
|
||||
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
$hash = $this->hasher->hashPassword($user, $password);
|
||||
// Prüfen und Anlegen in einer Transaktion: zwei gleichzeitige Absendungen sollen nicht zwei
|
||||
// erste Konten ergeben.
|
||||
$this->db->transactional(function () use ($user, $username, $hash): void {
|
||||
if ($this->isInstalled()) {
|
||||
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
|
||||
}
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => $user->id,
|
||||
'username' => $username,
|
||||
'password_hash' => $hash,
|
||||
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
// Der Schlüssel hat seine Arbeit getan; liegen bleiben soll er nicht.
|
||||
@unlink($this->root.'/'.self::KEY_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schlüssel dieser Installation; beim ersten Aufruf angelegt, wenn install.php es nicht
|
||||
* schon getan hat. 128 Bit aus random_bytes - raten lohnt nicht, eine Sperre braucht es nicht.
|
||||
*/
|
||||
public function installKey(): string
|
||||
{
|
||||
$file = $this->root.'/'.self::KEY_FILE;
|
||||
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
|
||||
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
|
||||
return $key;
|
||||
}
|
||||
if (!is_dir(\dirname($file)) && !@mkdir(\dirname($file), 0o775, true) && !is_dir(\dirname($file))) {
|
||||
throw new \RuntimeException('var/ ist nicht beschreibbar; ohne Installationsschlüssel geht es nicht weiter.');
|
||||
}
|
||||
$key = bin2hex(random_bytes(16));
|
||||
// x: nur anlegen, wenn es die Datei noch nicht gibt - zwei gleichzeitige erste Aufrufe
|
||||
// sollen sich nicht gegenseitig den Schlüssel überschreiben.
|
||||
$handle = @fopen($file, 'x');
|
||||
if (false === $handle) {
|
||||
$existing = trim((string) @file_get_contents($file));
|
||||
if (1 === preg_match('/^[a-f0-9]{32}$/D', $existing)) {
|
||||
return $existing;
|
||||
}
|
||||
throw new \RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben ('.self::KEY_FILE.').');
|
||||
}
|
||||
fwrite($handle, $key.'
|
||||
');
|
||||
fclose($handle);
|
||||
@chmod($file, 0o600);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/** Ob die Eingabe der Schlüssel ist - Leerzeichen und Großbuchstaben aus dem Abtippen zählen nicht. */
|
||||
public function acceptsKey(string $given): bool
|
||||
{
|
||||
return hash_equals($this->installKey(), strtolower(preg_replace('/\s+/', '', $given) ?? ''));
|
||||
}
|
||||
|
||||
/** Was als Cookie im Browser bleibt: nicht der Schlüssel selbst, sondern ein Nachweis über ihn. */
|
||||
public function keyProof(): string
|
||||
{
|
||||
return hash_hmac('sha256', 'installer', $this->installKey());
|
||||
}
|
||||
|
||||
public function acceptsProof(string $proof): bool
|
||||
{
|
||||
return '' !== $proof && hash_equals($this->keyProof(), $proof);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,9 +439,8 @@ final class Installation
|
||||
*
|
||||
* The session cookie of this application is scoped to `/admin`, so the installer has no
|
||||
* session - and it should not get one for six screens. The signature ties a form to a step and
|
||||
* to a point in time. It is not protection against somebody who can reach the installer: that
|
||||
* protection is that the installer stops existing after the first account, which is why the
|
||||
* last screen says to install right after uploading.
|
||||
* to a point in time. Wer überhaupt an den Installer darf, entscheidet seit ADR 116 der
|
||||
* Installationsschlüssel; die Unterschrift bindet nur das Formular an seinen Schritt.
|
||||
*/
|
||||
public function sign(string $step, int $issued): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\Transport;
|
||||
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.
|
||||
*/
|
||||
/** Baut einen Transport aus einer DSN, die (noch) nicht eingestellt ist - zum Ausprobieren. */
|
||||
private \Closure $fabrik;
|
||||
|
||||
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile, ?\Closure $fabrik = null)
|
||||
{
|
||||
$this->fabrik = $fabrik ?? static fn (string $dsn): TransportInterface => Transport::fromDsn($dsn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Wege, die ein gewöhnliches Hosting ohne Zugangsdaten anbietet (ADR 122). Bei IONOS etwa
|
||||
* scheitert sendmail -bs, mail() geht.
|
||||
*
|
||||
* @return array<string, array{label: string, dsn: string}>
|
||||
*/
|
||||
public static function serverWege(): array
|
||||
{
|
||||
return [
|
||||
'native' => ['label' => 'PHP mail() des Servers', 'dsn' => 'native://default'],
|
||||
'sendmail-t' => ['label' => 'sendmail -t des Servers', 'dsn' => 'sendmail://default?command=/usr/sbin/sendmail%20-t%20-i'],
|
||||
'sendmail-bs' => ['label' => 'sendmail -bs des Servers', 'dsn' => 'sendmail://default'],
|
||||
];
|
||||
}
|
||||
|
||||
/** smtp://… aus Einzelfeldern; Benutzer und Passwort URL-kodiert. Port 465 heißt smtps. */
|
||||
public static function smtpDsn(string $server, int $port, string $benutzer, string $passwort): string
|
||||
{
|
||||
$server = trim($server);
|
||||
if (1 !== preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/iD', $server)) {
|
||||
throw new \InvalidArgumentException('Der SMTP-Server ist kein gültiger Rechnername, etwa smtp.ionos.de.');
|
||||
}
|
||||
if ($port < 1 || $port > 65535) {
|
||||
throw new \InvalidArgumentException('Der Port muss zwischen 1 und 65535 liegen, meist 587.');
|
||||
}
|
||||
$anmeldung = '' === $benutzer ? '' : rawurlencode($benutzer).('' === $passwort ? '' : ':'.rawurlencode($passwort)).'@';
|
||||
|
||||
return (465 === $port ? 'smtps' : 'smtp').'://'.$anmeldung.strtolower($server).':'.$port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die aktuelle Einstellung zum Vorbelegen der Felder - ohne Passwort, nur ob es eins gibt.
|
||||
*
|
||||
* @return array{weg: string, server: string, port: int, benutzer: string, passwort: bool}
|
||||
*/
|
||||
public function einstellung(): array
|
||||
{
|
||||
$leer = ['weg' => 'smtp', 'server' => '', 'port' => 587, 'benutzer' => '', 'passwort' => false];
|
||||
foreach (self::serverWege() as $schluessel => $weg) {
|
||||
if ($weg['dsn'] === $this->dsn) {
|
||||
return ['weg' => $schluessel] + $leer;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$dsn = Dsn::fromString($this->dsn);
|
||||
} catch (\Throwable) {
|
||||
return $leer;
|
||||
}
|
||||
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true)) {
|
||||
return ['weg' => 'native' === $dsn->getScheme() ? 'native' : ('sendmail' === $dsn->getScheme() ? 'sendmail-bs' : 'smtp')] + $leer;
|
||||
}
|
||||
|
||||
return [
|
||||
'weg' => 'smtp',
|
||||
'server' => $dsn->getHost(),
|
||||
'port' => $dsn->getPort() ?? ('smtps' === $dsn->getScheme() ? 465 : 587),
|
||||
'benutzer' => (string) $dsn->getUser(),
|
||||
'passwort' => '' !== (string) $dsn->getPassword(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Das gespeicherte SMTP-Passwort, wenn Server und Benutzer gleich bleiben - sonst leer. */
|
||||
public function gespeichertesPasswort(string $server, string $benutzer): string
|
||||
{
|
||||
try {
|
||||
$dsn = Dsn::fromString($this->dsn);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true) || strtolower($dsn->getHost()) !== strtolower(trim($server)) || (string) $dsn->getUser() !== $benutzer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $dsn->getPassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf
|
||||
* keine Anfrage und keine Seite kosten.
|
||||
*
|
||||
* Mit $dsn geht der Versand über diesen Weg statt über den eingestellten - zum Ausprobieren
|
||||
* vor dem Speichern.
|
||||
*
|
||||
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string, weg: string}
|
||||
*/
|
||||
public function send(Email $email, string $anlass, ?string $dsn = null): array
|
||||
{
|
||||
$an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo()));
|
||||
$scheme = self::schemeVon($dsn ?? $this->dsn);
|
||||
try {
|
||||
// Derselbe Weg wie eingestellt: der Transport des Frameworks, samt seinen Ereignissen.
|
||||
$transport = null === $dsn || $dsn === $this->dsn ? $this->transport : ($this->fabrik)($dsn);
|
||||
$gesendet = $transport->send($email);
|
||||
$ergebnis = [
|
||||
'ok' => true,
|
||||
'meldung' => 'Vom Mailserver angenommen.',
|
||||
'hinweis' => self::hinweisNachErfolg($scheme),
|
||||
'dialog' => self::dialog((string) $gesendet?->getDebug()),
|
||||
];
|
||||
} catch (TransportExceptionInterface $fehler) {
|
||||
$ergebnis = [
|
||||
'ok' => false,
|
||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $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(), $scheme),
|
||||
'dialog' => '',
|
||||
];
|
||||
}
|
||||
$weg = $this->route($dsn);
|
||||
$ergebnis['weg'] = trim($weg['art'].' '.$weg['ziel']);
|
||||
$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(?string $roh = null): array
|
||||
{
|
||||
try {
|
||||
$dsn = Dsn::fromString($roh ?? $this->dsn);
|
||||
} catch (\Throwable) {
|
||||
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
|
||||
}
|
||||
$art = match ($dsn->getScheme()) {
|
||||
'smtp', 'smtps' => 'SMTP',
|
||||
'sendmail', 'sendmail+smtp' => str_contains((string) $dsn->getOption('command'), ' -t') ? 'sendmail -t des Servers' : 'sendmail -bs 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;
|
||||
}
|
||||
|
||||
/** Protokoll leeren: die Datei und ihre ältere Hälfte. */
|
||||
public function clearLog(): void
|
||||
{
|
||||
foreach ([$this->logFile, $this->logFile.'.1'] as $datei) {
|
||||
if (is_file($datei)) {
|
||||
@unlink($datei);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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')) => 'Die Einstellung des Versands ist ungültig. Unter System → E-Mail-Versand neu setzen.',
|
||||
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($m, 'sender address is not allowed') || str_contains($m, 'envelope from address') => 'Der Server lehnt den Absender ab. Beim Versand über den Server (mail(), sendmail) muss der Absender zu einer eigenen Domain beim Hoster gehören, nicht etwa Gmail; bei SMTP ist es meist genau das angemeldete Postfach.',
|
||||
str_contains($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Zuerst „PHP mail() des Servers“ probieren, dauerhaft besser SMTP mit einem echten Postfach.',
|
||||
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 static function schemeVon(string $roh): string
|
||||
{
|
||||
try {
|
||||
return Dsn::fromString($roh)->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);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ namespace App\Ops;
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
/**
|
||||
* Bewusst nicht unter /.well-known/: IONOS beantwortet alles darunter selbst mit 403, bevor
|
||||
* die Anfrage die Installation erreicht (huefner-gartenbau.de, 12.10.2026, ADR 122).
|
||||
*/
|
||||
public const PATH = '/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Sperrdateien, die dafür sorgen, dass nichts außer public/ über das Netz erreichbar ist - auch
|
||||
* wenn die Domain versehentlich auf den Projektordner zeigt (ADR 116).
|
||||
*
|
||||
* Die meisten liegen im Release (bin/, config/, src/ …, vendor/ und der Projektstamm). Zwei
|
||||
* entstehen erst auf dem Server: var/ gibt es im Archiv nicht, und die Datei im Projektstamm soll
|
||||
* auch eine Installation bekommen, die von einer Fassung vor 1.30.0 aktualisiert wurde - deren
|
||||
* Updater kannte sie noch nicht. Angelegt wird nur, was fehlt; eine eigene .htaccess des Betreibers
|
||||
* im Projektstamm bleibt unberührt.
|
||||
*/
|
||||
final class Protection
|
||||
{
|
||||
private static bool $checked = false;
|
||||
|
||||
public static function ensure(string $root): void
|
||||
{
|
||||
if (self::$checked) {
|
||||
return;
|
||||
}
|
||||
self::$checked = true;
|
||||
$sperren = $root.'/config/schutz/sperren.htaccess';
|
||||
$stamm = $root.'/config/schutz/stamm.htaccess';
|
||||
foreach ([$root.'/var/.htaccess' => $sperren, $root.'/vendor/.htaccess' => $sperren, $root.'/.htaccess' => $stamm] as $ziel => $vorlage) {
|
||||
if (!is_file($ziel) && is_dir(\dirname($ziel)) && is_file($vorlage)) {
|
||||
@copy($vorlage, $ziel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Für Tests: die nächste Anfrage prüft wieder. */
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$checked = false;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale;
|
||||
|
||||
/**
|
||||
* Meldet sich bei der Zentrale und führt einen dort angestoßenen Auftrag aus (ADR 123).
|
||||
*
|
||||
* Der einzige Auftrag ist „Update auf Fassung X“. Er läuft über denselben Weg wie der Knopf in
|
||||
* der Verwaltung: Sicherung, Archiv mit Prüfsumme, Dateien tauschen. Den zweiten Schritt - die
|
||||
* Migrationen mit dem neuen Code - erledigt die nächste Anfrage (@see RemoteUpdateFinishSubscriber);
|
||||
* diese Aufgabe stößt sie gleich selbst an, damit die Wartungsseite nicht bis zum nächsten
|
||||
* Besucher stehen bleibt.
|
||||
*/
|
||||
final class ZentraleTask implements Task
|
||||
{
|
||||
/** Zeitgrenze für den Anstoß des zweiten Schritts; die Migrationen laufen in dieser Anfrage. */
|
||||
private const ABSCHLUSS_SEKUNDEN = 300;
|
||||
|
||||
/** @param (\Closure(string): void)|null $anstossen ruft die Website einmal auf; Tests setzen ihn */
|
||||
public function __construct(
|
||||
private Zentrale $zentrale,
|
||||
private ArchiveUpdate $archive,
|
||||
private Updater $updater,
|
||||
private string $siteOrigin,
|
||||
private string $projectDir,
|
||||
private ?\Closure $anstossen = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'zentrale';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bei der Zentrale melden';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Meldet Adresse und Fassung an sites.kant-it.de und holt dort angestoßene Updates ab. Ohne sie fehlt die Website in der Übersicht, und ein Update aus der Ferne kommt nicht an.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$grund = $this->zentrale->grundInaktiv();
|
||||
if (null !== $grund) {
|
||||
return $grund;
|
||||
}
|
||||
$this->ergebnisNachtragen();
|
||||
$antwort = $this->zentrale->melden();
|
||||
$auftrag = $antwort['auftrag'];
|
||||
if (null === $auftrag) {
|
||||
return sprintf('Gemeldet: Fassung %s.', $this->updater->version());
|
||||
}
|
||||
|
||||
return $this->ausfuehren($auftrag['id'], $auftrag['aktion'], $auftrag['fassung']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Auftrag, dessen zweiter Schritt in einer anderen Anfrage lief: Was ist daraus geworden?
|
||||
* Steht nichts mehr aus, verrät es der Fortschritt des Updates.
|
||||
*/
|
||||
private function ergebnisNachtragen(): void
|
||||
{
|
||||
$auftrag = $this->zentrale->offenerAuftrag();
|
||||
if (null === $auftrag || array_key_exists('ok', $auftrag) || null !== $this->archive->pending()) {
|
||||
return;
|
||||
}
|
||||
$status = $this->archive->status() ?? [];
|
||||
if ($status['laeuft'] ?? false) {
|
||||
return;
|
||||
}
|
||||
$phase = (string) ($status['phase'] ?? '');
|
||||
$fassung = (string) ($auftrag['fassung'] ?? '');
|
||||
$ok = 'fertig' === $phase || version_compare($this->updater->version(), $fassung, '>=');
|
||||
$this->zentrale->auftragErledigt($ok, (string) ($status['meldung'] ?? ($ok ? 'Fassung '.$this->updater->version().' läuft.' : 'Ohne Ergebnis beendet.')));
|
||||
}
|
||||
|
||||
private function ausfuehren(int $id, string $aktion, string $fassung): string
|
||||
{
|
||||
$this->zentrale->auftragAngenommen($id, $fassung);
|
||||
$ablehnen = function (string $grund): string {
|
||||
$this->zentrale->auftragErledigt(false, $grund);
|
||||
$this->zentrale->melden();
|
||||
|
||||
return 'Auftrag abgelehnt: '.$grund;
|
||||
};
|
||||
if ('update' !== $aktion) {
|
||||
return $ablehnen(sprintf('Unbekannter Auftrag „%s“.', $aktion));
|
||||
}
|
||||
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $fassung)) {
|
||||
return $ablehnen('Die Fassung hat nicht die Form 1.2.3.');
|
||||
}
|
||||
if (version_compare($fassung, $this->updater->version(), '<=')) {
|
||||
$this->zentrale->auftragErledigt(true, sprintf('Bereits auf Fassung %s.', $this->updater->version()));
|
||||
$this->zentrale->melden();
|
||||
|
||||
return 'Auftrag erledigt: Diese Installation ist schon auf dem Stand.';
|
||||
}
|
||||
if (is_dir($this->projectDir.'/.git')) {
|
||||
return $ablehnen('Diese Installation ist eine Git-Arbeitskopie; sie wird über Git aktualisiert, nicht aus der Ferne.');
|
||||
}
|
||||
if (null !== $this->archive->pending() || is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) {
|
||||
return $ablehnen('Hier läuft gerade ein Update oder die Wartungsseite ist an.');
|
||||
}
|
||||
|
||||
// Ein Update dauert länger als eine gewöhnliche Aufgabe. Die Antwort an den Besucher ist
|
||||
// da schon unterwegs; abbrechen darf der Lauf jetzt nicht mehr.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->run($fassung);
|
||||
if (!($ergebnis['migrate'] ?? false)) {
|
||||
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
|
||||
$this->zentrale->auftragErledigt($ergebnis['ok'], (string) $letzter['detail']);
|
||||
$this->zentrale->melden();
|
||||
|
||||
return ($ergebnis['ok'] ? 'Auftrag erledigt: ' : 'Update gescheitert: ').$letzter['detail'];
|
||||
}
|
||||
|
||||
// Die neuen Dateien liegen; die Migrationen gehören in eine Anfrage mit dem neuen Code.
|
||||
$this->archive->markiereFern();
|
||||
$this->anstossen();
|
||||
$this->ergebnisNachtragen();
|
||||
try {
|
||||
$this->zentrale->melden();
|
||||
} catch (\Throwable) {
|
||||
// Das Ergebnis bleibt in var/zentrale.json und geht mit der nächsten Meldung raus.
|
||||
}
|
||||
|
||||
return null === $this->archive->pending()
|
||||
? sprintf('Update auf %s aus der Ferne ausgeführt.', $fassung)
|
||||
: sprintf('Dateien für %s getauscht; die Migrationen laufen mit der nächsten Anfrage.', $fassung);
|
||||
}
|
||||
|
||||
/** Ruft die Website einmal auf. Gelingt das nicht, schließt der nächste Besucher ab. */
|
||||
private function anstossen(): void
|
||||
{
|
||||
$adresse = rtrim($this->siteOrigin, '/').'/';
|
||||
if (null !== $this->anstossen) {
|
||||
($this->anstossen)($adresse);
|
||||
|
||||
return;
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($adresse);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => self::ABSCHLUSS_SEKUNDEN,
|
||||
CURLOPT_USERAGENT => 'KantSites-Abschluss',
|
||||
]);
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
||||
|
||||
return;
|
||||
}
|
||||
$kontext = stream_context_create(['http' => ['timeout' => self::ABSCHLUSS_SEKUNDEN, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Abschluss\r\n"]]);
|
||||
@file_get_contents($adresse, false, $kontext);
|
||||
}
|
||||
}
|
||||
+27
-4
@@ -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']
|
||||
@@ -231,6 +231,12 @@ final class Updater
|
||||
$step($steps, 'Vorprüfung', true, 'Alle Prüfungen bestanden.');
|
||||
|
||||
$target ??= $this->availableVersion();
|
||||
// Ein Tag der Form v1.2.3 und sonst nichts: der Wert geht an git checkout (ADR 116).
|
||||
if (null !== $target && 1 !== preg_match('/^v\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $target)) {
|
||||
$step($steps, 'Ziel bestimmen', false, 'Das Ziel hat nicht die Form v1.2.3.');
|
||||
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||
}
|
||||
if (null === $target) {
|
||||
$step($steps, 'Ziel bestimmen', false, 'Die Herkunft bietet keinen Tag der Form v1.2.3 an.');
|
||||
|
||||
@@ -293,8 +299,13 @@ final class Updater
|
||||
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
|
||||
}
|
||||
}
|
||||
$checkout = $this->git(['checkout', '--force', $target]);
|
||||
$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');
|
||||
}
|
||||
@@ -438,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,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Die Meldung an die Zentrale (ADR 123): welche Domain, welche Fassung - und ob ein Auftrag wartet.
|
||||
*
|
||||
* Jede Installation meldet sich regelmäßig bei sites.kant-it.de. Dort steht die Liste aller
|
||||
* Installationen; dort lässt sich ein Update anstoßen. Die Zentrale verbindet sich nie selbst mit
|
||||
* einer Website: Sie merkt sich den Auftrag, und die Website holt ihn mit der nächsten Meldung ab.
|
||||
* Damit braucht keine Installation eine Tür, die sich von außen öffnen lässt.
|
||||
*
|
||||
* Was gemeldet wird, steht in payload(): Adresse, Fassung, PHP, Datenbankart, Updatestand. Keine
|
||||
* Inhalte, keine Besucher, keine Zugangsdaten.
|
||||
*
|
||||
* Die Echtheit: Beim ersten Kontakt schickt die Installation einen zufälligen Schlüssel mit (über
|
||||
* HTTPS). Danach trägt jede Meldung eine HMAC-Unterschrift damit, und jede Antwort der Zentrale
|
||||
* ebenso. Wer die Kennung einer Installation kennt, kann ohne den Schlüssel weder für sie melden
|
||||
* noch ihr einen Auftrag unterschieben. Ein Auftrag kann ohnehin nur eine veröffentlichte Fassung
|
||||
* mit passender Prüfsumme einspielen (@see ArchiveUpdate).
|
||||
*/
|
||||
class Zentrale
|
||||
{
|
||||
/** Kennung, Schlüssel und offener Auftrag dieser Installation. Bleibt bei Updates erhalten. */
|
||||
public const DATEI = 'var/zentrale.json';
|
||||
|
||||
private const PFAD = '/api/melden';
|
||||
|
||||
/** @var (\Closure(string, string, array<string, string>): array{status: int, body: string, signatur: string})|null */
|
||||
private ?\Closure $senden;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
private ArchiveUpdate $archive,
|
||||
private string $url,
|
||||
private string $siteOrigin,
|
||||
private string $databaseUrl,
|
||||
?\Closure $senden = null,
|
||||
) {
|
||||
$this->senden = $senden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet diese Installation? Nein, wenn die Zentrale abgeschaltet ist (ZENTRALE_URL=aus) oder
|
||||
* die Website nur auf diesem Rechner läuft - Entwicklungs- und Messinstallationen gehören
|
||||
* nicht in die Liste.
|
||||
*/
|
||||
public function aktiv(): bool
|
||||
{
|
||||
return null === $this->grundInaktiv();
|
||||
}
|
||||
|
||||
public function grundInaktiv(): ?string
|
||||
{
|
||||
if (!str_starts_with($this->url, 'https://')) {
|
||||
return 'Abgeschaltet (ZENTRALE_URL ist nicht gesetzt oder keine HTTPS-Adresse).';
|
||||
}
|
||||
$host = strtolower((string) parse_url($this->siteOrigin, PHP_URL_HOST));
|
||||
if ('' === $host || in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true) || str_ends_with($host, '.localhost') || str_ends_with($host, '.test') || str_ends_with($host, '.invalid')) {
|
||||
return sprintf('Keine Meldung: Die Website läuft lokal (%s).', '' === $host ? 'ohne SITE_ORIGIN' : $host);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt die Meldung und gibt den Auftrag zurück, falls einer wartet.
|
||||
*
|
||||
* @return array{auftrag: ?array{id: int, aktion: string, fassung: string}, registriert: bool}
|
||||
*
|
||||
* @throws \RuntimeException wenn die Zentrale nicht oder nicht echt antwortet
|
||||
*/
|
||||
public function melden(): array
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
$erneut = false;
|
||||
while (true) {
|
||||
$daten = $this->payload($ich);
|
||||
$body = json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
$antwort = $this->post(rtrim($this->url, '/').self::PFAD, $body, [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-KantSites-Signatur' => hash_hmac('sha256', $body, $ich['schluessel']),
|
||||
]);
|
||||
// Die Zentrale kennt diese Kennung nicht (mehr) - etwa nach einem Neuaufsetzen dort.
|
||||
// Dann einmal mit Schlüssel neu anmelden.
|
||||
if (409 === $antwort['status'] && !$erneut) {
|
||||
$ich['registriert'] = false;
|
||||
$erneut = true;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (200 !== $antwort['status']) {
|
||||
throw new \RuntimeException(sprintf('Die Zentrale antwortet mit %d. %s', $antwort['status'], $this->fehlertext($antwort['body'])));
|
||||
}
|
||||
if (!hash_equals(hash_hmac('sha256', $antwort['body'], $ich['schluessel']), $antwort['signatur'])) {
|
||||
throw new \RuntimeException('Die Antwort der Zentrale trägt keine gültige Unterschrift und wird verworfen.');
|
||||
}
|
||||
$inhalt = json_decode($antwort['body'], true);
|
||||
if (!is_array($inhalt) || true !== ($inhalt['ok'] ?? null)) {
|
||||
throw new \RuntimeException('Die Antwort der Zentrale ist unverständlich.');
|
||||
}
|
||||
|
||||
$ich['registriert'] = true;
|
||||
$ich['gemeldet'] = gmdate('c');
|
||||
// Ein gemeldetes Ergebnis ist angekommen; der Auftrag ist damit erledigt.
|
||||
if (isset($daten['auftrag']['ok'])) {
|
||||
unset($ich['auftrag']);
|
||||
}
|
||||
$this->speichern($ich);
|
||||
|
||||
$auftrag = $inhalt['auftrag'] ?? null;
|
||||
if (!is_array($auftrag) || !is_int($auftrag['id'] ?? null) || !is_string($auftrag['aktion'] ?? null) || !is_string($auftrag['fassung'] ?? null)) {
|
||||
$auftrag = null;
|
||||
}
|
||||
|
||||
return ['auftrag' => null === $auftrag ? null : ['id' => $auftrag['id'], 'aktion' => $auftrag['aktion'], 'fassung' => $auftrag['fassung']], 'registriert' => true];
|
||||
}
|
||||
|
||||
/** Merkt sich einen angenommenen Auftrag, bis sein Ergebnis gemeldet ist. */
|
||||
public function auftragAngenommen(int $id, string $fassung): void
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
$ich['auftrag'] = ['id' => $id, 'fassung' => $fassung, 'seit' => gmdate('c')];
|
||||
$this->speichern($ich);
|
||||
}
|
||||
|
||||
/** Hält das Ergebnis eines Auftrags fest; die nächste Meldung trägt es zur Zentrale. */
|
||||
public function auftragErledigt(bool $ok, string $meldung): void
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
if (!isset($ich['auftrag'])) {
|
||||
return;
|
||||
}
|
||||
$ich['auftrag']['ok'] = $ok;
|
||||
$ich['auftrag']['meldung'] = mb_substr($meldung, 0, 500);
|
||||
$this->speichern($ich);
|
||||
}
|
||||
|
||||
/** @return array{id?: int, fassung?: string, seit?: string, ok?: bool, meldung?: string}|null */
|
||||
public function offenerAuftrag(): ?array
|
||||
{
|
||||
return $this->identitaet()['auftrag'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was gemeldet wird.
|
||||
*
|
||||
* @param array<string, mixed> $ich
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payload(array $ich): array
|
||||
{
|
||||
$status = $this->archive->status();
|
||||
$daten = [
|
||||
'id' => $ich['id'],
|
||||
'adresse' => rtrim($this->siteOrigin, '/'),
|
||||
'fassung' => $this->updater->version(),
|
||||
'php' => PHP_VERSION,
|
||||
'datenbank' => strtolower((string) strtok($this->databaseUrl, ':')),
|
||||
// Über Git aktualisierte Installationen nimmt die Zentrale nicht in Auftrag.
|
||||
'art' => is_dir($this->root.'/.git') ? 'git' : 'archiv',
|
||||
'wartung' => is_file($this->root.'/'.Updater::MAINTENANCE_FILE),
|
||||
'update' => null === $status ? null : [
|
||||
'phase' => (string) ($status['phase'] ?? ''),
|
||||
'meldung' => mb_substr((string) ($status['meldung'] ?? ''), 0, 300),
|
||||
'zeit' => (string) ($status['aktualisiert'] ?? ''),
|
||||
],
|
||||
];
|
||||
if (!($ich['registriert'] ?? false)) {
|
||||
$daten['schluessel'] = $ich['schluessel'];
|
||||
}
|
||||
$auftrag = $ich['auftrag'] ?? null;
|
||||
if (is_array($auftrag)) {
|
||||
$daten['auftrag'] = array_key_exists('ok', $auftrag)
|
||||
? ['id' => $auftrag['id'], 'ok' => $auftrag['ok'], 'meldung' => $auftrag['meldung'] ?? '']
|
||||
: ['id' => $auftrag['id']];
|
||||
}
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function identitaet(): array
|
||||
{
|
||||
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::DATEI), true);
|
||||
if (is_array($daten) && is_string($daten['id'] ?? null) && is_string($daten['schluessel'] ?? null)) {
|
||||
return $daten;
|
||||
}
|
||||
$daten = ['id' => bin2hex(random_bytes(16)), 'schluessel' => bin2hex(random_bytes(32)), 'registriert' => false];
|
||||
$this->speichern($daten);
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function speichern(array $daten): void
|
||||
{
|
||||
$datei = $this->root.'/'.self::DATEI;
|
||||
@mkdir(dirname($datei), 0o775, true);
|
||||
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (false === @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) || !@rename($zwischen, $datei)) {
|
||||
@unlink($zwischen);
|
||||
throw new \RuntimeException(self::DATEI.' ist nicht schreibbar.');
|
||||
}
|
||||
}
|
||||
|
||||
private function fehlertext(string $body): string
|
||||
{
|
||||
$daten = json_decode($body, true);
|
||||
|
||||
return is_array($daten) && is_string($daten['fehler'] ?? null) ? mb_substr($daten['fehler'], 0, 200) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST über HTTPS, ohne Weiterleitungen: Eine Meldung, die woanders hin umgelenkt wird, soll
|
||||
* scheitern und nicht ankommen.
|
||||
*
|
||||
* @param array<string, string> $kopf
|
||||
*
|
||||
* @return array{status: int, body: string, signatur: string}
|
||||
*/
|
||||
private function post(string $url, string $body, array $kopf): array
|
||||
{
|
||||
if (null !== $this->senden) {
|
||||
return ($this->senden)($url, $body, $kopf);
|
||||
}
|
||||
$zeilen = ['User-Agent: KantSites/'.$this->updater->version()];
|
||||
foreach ($kopf as $name => $wert) {
|
||||
$zeilen[] = $name.': '.$wert;
|
||||
}
|
||||
$signatur = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => $zeilen,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HEADERFUNCTION => static function ($curl, string $zeile) use (&$signatur): int {
|
||||
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $treffer)) {
|
||||
$signatur = strtolower($treffer[1]);
|
||||
}
|
||||
|
||||
return strlen($zeile);
|
||||
},
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$fehler = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if (!is_string($antwort)) {
|
||||
throw new \RuntimeException('Die Zentrale ist nicht erreichbar: '.$fehler);
|
||||
}
|
||||
|
||||
return ['status' => $status, 'body' => $antwort, 'signatur' => $signatur];
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
throw new \RuntimeException('Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.');
|
||||
}
|
||||
$kontext = stream_context_create([
|
||||
'http' => ['method' => 'POST', 'header' => implode("\r\n", $zeilen), 'content' => $body, 'timeout' => 20, 'follow_location' => 0, 'ignore_errors' => true],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $kontext);
|
||||
$kopfzeilen = $http_response_header ?? [];
|
||||
if (!is_string($antwort) || [] === $kopfzeilen) {
|
||||
throw new \RuntimeException('Die Zentrale ist nicht erreichbar.');
|
||||
}
|
||||
preg_match('/^HTTP\/\S+\s+(\d{3})/', (string) $kopfzeilen[0], $treffer);
|
||||
foreach ($kopfzeilen as $zeile) {
|
||||
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $s)) {
|
||||
$signatur = strtolower($s[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => (int) ($treffer[1] ?? 0), 'body' => $antwort, 'signatur' => $signatur];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ final class UserDirectory
|
||||
public const ROLE_HELP = [
|
||||
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen – auch nicht Adresse, Papierkorb, Bereiche oder Beitragsdaten einer veröffentlichten Seite.',
|
||||
];
|
||||
|
||||
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Ein Versuch, gleich gebaut für „Letzte Versuche“ und „Alle Wege ausprobiert“: Zustand, worum es
|
||||
ging, wann - bei Fehlern der Hinweis vorn und die Meldung des Servers zugeklappt dahinter. #}
|
||||
<li class="mail-entry">
|
||||
<span class="pill {{ eintrag.ok ? 'pill-live' : 'pill-error' }}">{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</span>
|
||||
<div class="mail-entry-text">
|
||||
<p class="mail-entry-line">
|
||||
{% if titel is defined %}<strong>{{ titel }}</strong>{% else %}{{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} <span class="muted">· {{ eintrag.weg }}</span>{% endif %}{% endif %}
|
||||
</p>
|
||||
{% if eintrag.zeit is defined %}<p class="mail-entry-time">{{ eintrag.zeit }} UTC</p>{% endif %}
|
||||
{% if not eintrag.ok %}
|
||||
{% if eintrag.hinweis %}<p class="mail-entry-hint">{{ eintrag.hinweis }}</p>{% endif %}
|
||||
<details class="mail-entry-detail"><summary>Meldung des Servers</summary><pre>{{ eintrag.meldung }}</pre></details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'mail' %}
|
||||
{% block title %}E-Mail-Versand{% endblock %}
|
||||
{% block heading %}E-Mail-Versand{% endblock %}
|
||||
{% block lead %}Wie Formulare ihre Mails verschicken – ausprobieren, speichern und sehen, woran es hakt.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# E-Mail-Versand (ADR 121, 122): Weg wählen und ausprobieren, alle Wege des Servers testen,
|
||||
speichern - und die letzten Versuche mit Ursache. Das SMTP-Passwort steht nie im Formular. #}
|
||||
{% set f = mailForm ?? {} %}
|
||||
{% set e = mail.einstellung %}
|
||||
{% set weg = f.weg ?? e.weg %}
|
||||
{% set beschreibung = {
|
||||
smtp: 'Empfohlen. Ein echtes Postfach, unabhängig vom Hoster.',
|
||||
native: 'Über den Server. Der Absender muss zu einer Domain beim Hoster gehören.',
|
||||
'sendmail-t': 'Über den Server, falls mail() fehlt.',
|
||||
'sendmail-bs': 'Über den Server, bei manchen Hostern gesperrt.',
|
||||
} %}
|
||||
|
||||
{% if mailNotice %}
|
||||
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }}" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="card mail-form" id="mail" action="{{ path('admin_mail_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
|
||||
<div class="card-body">
|
||||
<div class="mail-current">
|
||||
<span class="muted">Eingestellt</span>
|
||||
{% if mail.gueltig %}
|
||||
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">als</span> <code>{{ mail.benutzer }}</code>{% endif %}
|
||||
{% else %}
|
||||
<strong class="danger-text">Die Einstellung ist ungültig.</strong>
|
||||
{% endif %}
|
||||
<span class="muted mail-current-source">in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>{% if mail.quelle != '.env' %} – hat Vorrang, speichern lässt sich hier nichts, testen schon{% endif %}</span>
|
||||
</div>
|
||||
|
||||
<fieldset class="card-section">
|
||||
<legend>Weg wählen</legend>
|
||||
<div class="option-list">
|
||||
<label class="option" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}><span><strong>SMTP mit Postfach</strong><span>{{ beschreibung.smtp }}</span></span></label>
|
||||
{% for key, w in mail.wege %}
|
||||
<label class="option" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}><span><strong>{{ w.label }}</strong><span>{{ beschreibung[key] ?? '' }}</span></span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="card-section smtp-fields">
|
||||
<legend>SMTP-Zugang</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-server">Server</label>
|
||||
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-port">Port</label>
|
||||
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-user">Benutzer</label>
|
||||
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-pass">Passwort</label>
|
||||
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="card-section">
|
||||
<legend>Testmail</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-to">An</label>
|
||||
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-from">Absender</label>
|
||||
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
|
||||
<div class="button-row">
|
||||
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
|
||||
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
|
||||
</div>
|
||||
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier.</p>
|
||||
|
||||
{% 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 mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% 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 %}
|
||||
|
||||
{% if mailResults %}
|
||||
<div class="mail-result">
|
||||
<p class="mail-result-head"><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ f.an ?? '' }}. Angenommen heißt noch nicht angekommen: bitte im Postfach nachsehen.</span></p>
|
||||
<ul class="mail-log">
|
||||
{% for r in mailResults %}
|
||||
{% include 'admin/_mail_entry.html.twig' with {eintrag: r, titel: r.weg} only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="card-foot form-foot">
|
||||
{% if mail.quelle == '.env' %}
|
||||
<button class="primary" type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>
|
||||
<span class="muted">Erst mit „Speichern“ versenden die Formulare auf dem gewählten Weg.</span>
|
||||
{% else %}
|
||||
<span class="muted">MAILER_DSN steht in der Server-Umgebung – speichern lässt sich hier nichts.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Letzte Versuche</h2>
|
||||
{% if mail.protokoll is empty %}
|
||||
<span class="muted">var/log/mail.log</span>
|
||||
{% else %}
|
||||
<form action="{{ path('admin_mail_clear') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="text-button" type="submit">Protokoll leeren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if mail.protokoll is empty %}
|
||||
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
|
||||
{% else %}
|
||||
<ul class="mail-log">
|
||||
{% for eintrag in mail.protokoll %}
|
||||
{% include 'admin/_mail_entry.html.twig' with {eintrag: eintrag} only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="field-help">Die letzten zehn Versuche aus <code>var/log/mail.log</code>, neueste zuerst. Zeiten in UTC.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -46,7 +46,15 @@
|
||||
<ul class="nav-list" aria-labelledby="nav-site">
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a>
|
||||
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. Sichtbar nur
|
||||
im Bereich System, sonst stünde das Menü dauerhaft aufgeklappt. #}
|
||||
{% if nav|default('') in ['system', 'mail'] %}
|
||||
<ul class="nav-sub">
|
||||
<li><a class="nav-item" href="{{ path('admin_mail') }}"{{ nav|default('') == 'mail' ? ' aria-current="page"' : '' }}>E-Mail-Versand</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -234,6 +263,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>
|
||||
|
||||
<form class="card-section" action="{{ path('admin_package_export') }}" method="post">
|
||||
<h3>Exportieren</h3>
|
||||
<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>
|
||||
<div class="button-row"><button class="secondary" type="submit">Paket herunterladen</button></div>
|
||||
</form>
|
||||
|
||||
<form class="card-section" action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
|
||||
<h3>Importieren</h3>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label class="field-first" 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-set">
|
||||
<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>
|
||||
<div class="button-row"><button class="primary" type="submit">Paket importieren</button></div>
|
||||
</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">
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Verknüpfte Sektion: unter Inhalt die Adresse einer Sektion eintragen.</p>{% endif %}
|
||||
{% elseif section %}
|
||||
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
{# Die Sektion nach ihrem eigenen Schema-Stand: ihr Text wurde nach diesem gefiltert, nicht nach dem der Seite (ADR 116). #}{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: section.schemaVersion|default(1) >= 12, richHeadings: section.schemaVersion|default(1) >= 17, neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif editing|default(false) %}
|
||||
<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
{# Bait for automated senders. Hidden from view, from assistive technology and from tab order. #}
|
||||
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
||||
|
||||
{% if errors is not empty %}
|
||||
{% if errors._form is defined %}
|
||||
<p class="form-error-summary" role="alert">{{ errors._form|kant_t }}</p>
|
||||
{% elseif errors is not empty %}
|
||||
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
||||
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet.
|
||||
Facebook, Instagram und WhatsApp am Ende: dieselben Formen wie social-icon.html.twig, damit sie
|
||||
auch als Symbol eines Menü-Buttons oder Bausteins wählbar sind. #}
|
||||
{% set paths = {
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
@@ -97,6 +99,9 @@
|
||||
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z'
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
facebook: 'M15.5 4h-2.2A3.8 3.8 0 0 0 9.5 7.8V10H7v3.2h2.5V20h3.3v-6.8h2.4l.5-3.2h-2.9V8.2c0-.6.3-.9.9-.9h2Z',
|
||||
instagram: 'M8.1 3.5h7.8a4.6 4.6 0 0 1 4.6 4.6v7.8a4.6 4.6 0 0 1-4.6 4.6H8.1a4.6 4.6 0 0 1-4.6-4.6V8.1a4.6 4.6 0 0 1 4.6-4.6ZM12 8.1a3.9 3.9 0 1 0 0 7.8 3.9 3.9 0 0 0 0-7.8ZM17 7h.01',
|
||||
whatsapp: 'M4 20l1.4-4A8.5 8.5 0 1 1 8.6 19.1ZM9.3 9.2c0 3 2.5 5.5 5.5 5.5l1.3-1.4-2-.9-.9.8a4.1 4.1 0 0 1-2.3-2.3l.8-.9-.9-2Z'
|
||||
} %}
|
||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
{# Strich, Farbe und Enden stehen als Regel für svg.block-icon in site.css und admin.css, nicht an jedem Symbol: eine Seite trägt um die 40 davon. #}<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends 'install/base.html.twig' %}
|
||||
{% block heading %}Installationsschlüssel{% endblock %}
|
||||
{% block lead %}
|
||||
<p class="muted">Damit niemand anderes diese Installation zu Ende führt, öffnet sich der Installer nur mit dem Schlüssel dieser Installation. Er steht auf der Abschlussseite von <code>install.php</code>. Wer die Dateien per FTP hochgeladen hat, findet ihn in der Datei <code>{{ keyFile }}</code> – herunterladen und öffnen, er besteht aus 32 Zeichen.</p>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" action="{{ path('install_key') }}">
|
||||
<label for="schluessel">Schlüssel</label>
|
||||
<input id="schluessel" name="schluessel" required maxlength="80" autocomplete="off" spellcheck="false" autofocus>
|
||||
<p class="muted">Mit dem ersten Konto ist die Installation abgeschlossen; dann wird der Schlüssel gelöscht.</p>
|
||||
<div class="sheet-actions">
|
||||
<button class="primary" type="submit">Weiter <span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -70,6 +70,7 @@
|
||||
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if needsConsent|default(false) and not editing|default(false) %}<script src="/assets/consent.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
|
||||
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
|
||||
show the layout, not run code that could change the page under the editor's hands. #}
|
||||
|
||||
+115
-1
@@ -8,16 +8,23 @@ use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\EventSubscriber\RemoteUpdateFinishSubscriber;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Tasks\ZentraleTask;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale;
|
||||
use App\Security\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
@@ -272,7 +279,9 @@ final class ArchiveUpdateTest extends TestCase
|
||||
// Beiwerk, das eine laufende Installation nicht liest.
|
||||
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
|
||||
// Betreiber installiertes bleibt stehen (ADR 102).
|
||||
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
|
||||
// Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören.
|
||||
// Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116).
|
||||
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess'];
|
||||
foreach ($liste($mit[1]) as $eintrag) {
|
||||
self::assertContains(
|
||||
$eintrag,
|
||||
@@ -335,6 +344,111 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
|
||||
}
|
||||
|
||||
public function testARemoteOrderUpdatesTheSiteAndTheNextRequestFinishesIt(): void
|
||||
{
|
||||
// ADR 123: Die Zentrale merkt sich „Update auf 1.1.0“, die Website holt es mit ihrer
|
||||
// Meldung ab, tauscht die Dateien, und die nächste Anfrage führt die Migrationen aus.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$server = $this->zentraleServer(['id' => 7, 'aktion' => 'update', 'fassung' => '1.1.0']);
|
||||
$zentrale = $this->zentrale($server['senden']);
|
||||
$task = new ZentraleTask($zentrale, $this->updater(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db), 'https://kunde.example.de', $this->root, function (string $adresse): void {
|
||||
self::assertSame('https://kunde.example.de/', $adresse);
|
||||
$this->naechsteAnfrage();
|
||||
});
|
||||
|
||||
$meldung = $task->run();
|
||||
|
||||
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), $meldung);
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die nächste Anfrage hat abgeschlossen und die Website freigegeben.');
|
||||
self::assertNull($this->updater()->pending());
|
||||
$meldungen = $server['meldungen']();
|
||||
self::assertArrayHasKey('schluessel', $meldungen[0], 'Beim ersten Kontakt geht der Schlüssel mit.');
|
||||
self::assertArrayNotHasKey('schluessel', $meldungen[1], 'Danach nur noch die Unterschrift.');
|
||||
self::assertSame('1.0.0', $meldungen[0]['fassung']);
|
||||
$letzte = end($meldungen);
|
||||
self::assertSame('1.1.0', $letzte['fassung']);
|
||||
self::assertSame(['id' => 7, 'ok' => true], array_intersect_key($letzte['auftrag'] ?? [], ['id' => 1, 'ok' => 1]), 'Das Ergebnis geht an die Zentrale zurück.');
|
||||
self::assertNull($zentrale->offenerAuftrag(), 'Nach der Quittung ist der Auftrag erledigt.');
|
||||
}
|
||||
|
||||
public function testAnAnswerWithoutAValidSignatureChangesNothing(): void
|
||||
{
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$zentrale = $this->zentrale(static fn (): array => [
|
||||
'status' => 200,
|
||||
'body' => '{"ok":true,"auftrag":{"id":1,"aktion":"update","fassung":"1.1.0"}}',
|
||||
'signatur' => str_repeat('0', 64),
|
||||
]);
|
||||
|
||||
try {
|
||||
$zentrale->melden();
|
||||
self::fail('Eine Antwort ohne gültige Unterschrift wurde angenommen.');
|
||||
} catch (\RuntimeException $fehler) {
|
||||
self::assertStringContainsString('Unterschrift', $fehler->getMessage());
|
||||
}
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
}
|
||||
|
||||
public function testALocalInstallationDoesNotReport(): void
|
||||
{
|
||||
$gesendet = false;
|
||||
$senden = static function () use (&$gesendet): array {
|
||||
$gesendet = true;
|
||||
|
||||
return ['status' => 500, 'body' => '', 'signatur' => ''];
|
||||
};
|
||||
foreach (['http://127.0.0.1:8106', 'http://localhost', 'https://kunde.test'] as $adresse) {
|
||||
$zentrale = new Zentrale($this->root, new Updater($this->root, 'sqlite:///x', new CommandRunner(), $this->db), $this->updater(), 'https://sites.example.invalid', $adresse, 'sqlite:///x', $senden);
|
||||
self::assertFalse($zentrale->aktiv(), $adresse);
|
||||
}
|
||||
self::assertFalse($this->zentrale(null, 'aus')->aktiv(), 'ZENTRALE_URL=aus schaltet ab.');
|
||||
self::assertFalse($gesendet);
|
||||
}
|
||||
|
||||
/** Die Anfrage nach dem Tausch: startet mit dem neuen Code und schließt ab. */
|
||||
private function naechsteAnfrage(): void
|
||||
{
|
||||
$pruefung = $this->createStub(Funktionspruefung::class);
|
||||
$pruefung->method('__invoke')->willReturn(null);
|
||||
$runner = new CommandRunner();
|
||||
$subscriber = new RemoteUpdateFinishSubscriber($this->updater(), $this->installation(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', $runner, $this->db), $pruefung, $this->root);
|
||||
$subscriber->onRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), Request::create('/'), HttpKernelInterface::MAIN_REQUEST));
|
||||
}
|
||||
|
||||
private function zentrale(?\Closure $senden, string $url = 'https://sites.example.invalid'): Zentrale
|
||||
{
|
||||
$url_db = 'sqlite:///'.$this->root.'/var/cms.db';
|
||||
|
||||
return new Zentrale($this->root, new Updater($this->root, $url_db, new CommandRunner(), $this->db), $this->updater(), $url, 'https://kunde.example.de', $url_db, $senden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zentrale im Speicher: lernt den Schlüssel beim ersten Kontakt, prüft danach jede
|
||||
* Unterschrift, gibt den Auftrag einmal heraus und unterschreibt jede Antwort.
|
||||
*
|
||||
* @return array{senden: \Closure, meldungen: \Closure}
|
||||
*/
|
||||
private function zentraleServer(?array $auftrag): array
|
||||
{
|
||||
$schluessel = null;
|
||||
$meldungen = [];
|
||||
$senden = function (string $url, string $body, array $kopf) use (&$schluessel, &$meldungen, &$auftrag): array {
|
||||
$daten = json_decode($body, true);
|
||||
$meldungen[] = $daten;
|
||||
$schluessel ??= $daten['schluessel'] ?? null;
|
||||
self::assertIsString($schluessel);
|
||||
self::assertTrue(hash_equals(hash_hmac('sha256', $body, $schluessel), $kopf['X-KantSites-Signatur']), 'Jede Meldung ist unterschrieben.');
|
||||
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag]);
|
||||
$auftrag = null;
|
||||
|
||||
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
||||
};
|
||||
|
||||
return ['senden' => $senden, 'meldungen' => static function () use (&$meldungen): array {
|
||||
return $meldungen;
|
||||
}];
|
||||
}
|
||||
|
||||
private function installation(): Installation
|
||||
{
|
||||
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
|
||||
|
||||
@@ -158,7 +158,7 @@ final class BacklogTest extends TestCase
|
||||
|
||||
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
|
||||
$issued = time() - 10;
|
||||
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
|
||||
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
|
||||
self::assertSame(422, $antwort->getStatusCode());
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
||||
}
|
||||
|
||||
+88
-7
@@ -311,7 +311,7 @@ final class CmsTest extends TestCase
|
||||
public static function rejectedButtonIcons(): iterable
|
||||
{
|
||||
yield 'Symbol vor v25' => ['icon', 'phone', 24];
|
||||
yield 'unbekanntes Symbol' => ['icon', 'whatsapp', 25];
|
||||
yield 'unbekanntes Symbol' => ['icon', 'telegram', 25];
|
||||
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||
@@ -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
|
||||
@@ -3098,6 +3101,26 @@ final class CmsTest extends TestCase
|
||||
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testALinkedSectionIsRenderedByItsOwnSchemaVersion(): void
|
||||
{
|
||||
// Eine Sektion mit altem Schema-Stand: ihr Text lief nicht durch den Filter für
|
||||
// formatierten Text. In einer neueren Seite darf er deshalb nicht roh erscheinen (ADR 116).
|
||||
$this->partials->create('section', 'alt', 'Alte Sektion', [
|
||||
'schemaVersion' => 11, 'title' => 'Alt', 'description' => 'Alte Sektion.',
|
||||
'blocks' => [['id' => 'alt-section', 'type' => 'section', 'children' => [
|
||||
['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => '<meta http-equiv="refresh" content="0;url=https://fremd.example"><a href="https://fremd.example">Hier</a>']],
|
||||
]]],
|
||||
]);
|
||||
$document = DemoContent::starter('Neu');
|
||||
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
|
||||
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'alt']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertStringNotContainsString('<meta http-equiv="refresh"', $html);
|
||||
self::assertStringContainsString('<meta http-equiv=', $html, 'Der Text erscheint als Text.');
|
||||
}
|
||||
|
||||
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
|
||||
{
|
||||
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
|
||||
@@ -3470,6 +3493,24 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testOwnCssStaysInsideItsBlock(): void
|
||||
{
|
||||
$code = new CustomCode();
|
||||
// Geschwister des Blocks selbst liegen außerhalb - die ganze Seite wäre umgestaltbar (ADR 116).
|
||||
foreach (['& ~ *{display:none}', '&+p{color:red}', '&[a="x]{color:red}', 'color:red /*', 'width:10px*/'] as $css) {
|
||||
try {
|
||||
$code->checkCss($css);
|
||||
self::fail('Angenommen: '.$css);
|
||||
} catch (InvalidDocument) {
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
// Was schon gespeichert ist und heute nicht mehr gilt, fällt still weg statt die Seite zu brechen.
|
||||
self::assertSame('', $code->compileCss('b1', $css));
|
||||
}
|
||||
// Innerhalb des Blocks bleiben ~ und + erlaubt.
|
||||
self::assertSame('#b1 .a~.b{color:red}#b1 h3+p{margin:0}', $code->compileCss('b1', '& .a~.b{color:red}& h3+p{margin:0}'));
|
||||
}
|
||||
|
||||
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
|
||||
{
|
||||
$code = new CustomCode();
|
||||
@@ -4546,8 +4587,10 @@ Achern']);
|
||||
self::assertStringContainsString('value="all"', $html);
|
||||
self::assertStringContainsString('value="none"', $html);
|
||||
self::assertStringContainsString('/datenschutz', $html);
|
||||
// No script anywhere: the strict policy stays as it is.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy'));
|
||||
// Seit ADR 115 kommt consent.js dazu, solange die Frage offen ist - als Datei von hier,
|
||||
// nie inline. Ohne Skript bleibt das Formular mit Weiterleitung.
|
||||
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
|
||||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||||
|
||||
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
|
||||
self::assertSame(302, $saved->getStatusCode());
|
||||
@@ -4561,6 +4604,9 @@ Achern']);
|
||||
// With a decision in hand the banner is gone, but the way back to it is not.
|
||||
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
|
||||
self::assertStringNotContainsString('consent-bar', $decided->getContent());
|
||||
// Nichts mehr offen: kein Skript, die Richtlinie wieder streng.
|
||||
self::assertStringNotContainsString('consent.js', $decided->getContent());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $decided->headers->get('Content-Security-Policy'));
|
||||
self::assertStringContainsString('/einwilligung', $decided->getContent());
|
||||
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
|
||||
}
|
||||
@@ -4601,10 +4647,45 @@ Achern']);
|
||||
self::assertStringNotContainsString('<iframe', $html);
|
||||
self::assertStringNotContainsString('karten.example/einbetten', $html);
|
||||
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
|
||||
self::assertStringContainsString("frame-src 'none'", $policy);
|
||||
// Die Erlaubnis steht schon in der Richtlinie (ADR 115): consent.js setzt den Rahmen
|
||||
// nach der Einwilligung ohne Neuladen ein, und eine Richtlinie ändert sich danach nicht
|
||||
// mehr. Geladen wird dadurch nichts - es gibt keinen Rahmen und keine Adresse.
|
||||
self::assertStringContainsString('frame-src https://karten.example', $policy);
|
||||
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheConsentFormRedirectsOnlyWithinThisSite(): void
|
||||
{
|
||||
$this->askConsent();
|
||||
// Browser entfernen Tabulator und Zeilenumbruch: aus /<Tab>/fremd würde //fremd (ADR 116).
|
||||
foreach (["/\t/fremd.example", "/\n/fremd.example", '//fremd.example', '/\fremd.example', 'https://fremd.example', '/ /fremd.example'] as $back) {
|
||||
self::assertSame('/', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $back])->headers->get('Location'), json_encode($back));
|
||||
}
|
||||
self::assertSame('/kontakt?a=1#x', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/kontakt?a=1#x'])->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheConsentScriptSavesWithoutARedirect(): void
|
||||
{
|
||||
$this->askConsent();
|
||||
|
||||
// consent.js kennzeichnet seine Anfrage; dann gibt es das Cookie ohne Weiterleitung.
|
||||
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/'], [], ['HTTP_X_KANT_CONSENT' => '1']);
|
||||
self::assertSame(204, $saved->getStatusCode());
|
||||
self::assertNull($saved->headers->get('Location'));
|
||||
self::assertSame('', $saved->getContent());
|
||||
$cookie = $saved->headers->getCookies()[0];
|
||||
self::assertSame(Consent::COOKIE, $cookie->getName());
|
||||
self::assertStringContainsString('external', (string) $cookie->getValue());
|
||||
|
||||
// Von einer fremden Website bleibt es bei der Antwort ohne Cookie, wie beim Formular.
|
||||
$fremd = $this->request('/einwilligung', 'POST', ['choice' => 'all'], [], ['HTTP_X_KANT_CONSENT' => '1', 'HTTP_SEC_FETCH_SITE' => 'cross-site']);
|
||||
self::assertSame([], $fremd->headers->getCookies());
|
||||
|
||||
// Ohne die Kopfzeile bleibt alles beim Alten: Weiterleitung zurück.
|
||||
self::assertSame(302, $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/'])->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Karte');
|
||||
@@ -4713,7 +4794,7 @@ Achern']);
|
||||
}
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
|
||||
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = [], array $server = []): Response
|
||||
{
|
||||
$previous = [];
|
||||
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
|
||||
@@ -4722,7 +4803,7 @@ Achern']);
|
||||
}
|
||||
$kernel = new Kernel('test', false);
|
||||
try {
|
||||
$request = Request::create($url, $method, $body, $cookies);
|
||||
$request = Request::create($url, $method, $body, $cookies, [], $server);
|
||||
$response = $kernel->handle($request);
|
||||
// Counting happens on kernel.terminate, so a test that does not terminate would
|
||||
// silently test a different application than the one that runs.
|
||||
|
||||
@@ -356,6 +356,54 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testEditorsChangeLivePagesOnlyThroughAPublisher(): void
|
||||
{
|
||||
// Eine veröffentlichte Unterseite und ein reiner Entwurf (ADR 116).
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$live = $pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
|
||||
$seite = $pages->findForEditor($live);
|
||||
$pages->publish($live, $seite['draft_revision_id'], $seite['version']);
|
||||
$entwurf = $pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$version = static fn (string $id): int => (int) $pages->findForEditor($id)['version'];
|
||||
|
||||
$client = $this->login('editor');
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
foreach (['move' => ['slug' => 'umbenannt'], 'trash' => [], 'bereiche' => ['headerArea' => '', 'footerArea' => 'none'], 'meta' => ['robots' => 'noindex']] as $aktion => $felder) {
|
||||
$client->request('POST', '/admin/pages/'.$live.'/'.$aktion, ['_csrf_token' => $token, 'expectedVersion' => $version($live), ...$felder]);
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
$nachher = $this->db->fetchAssociative('SELECT path, deleted_at, footer_area, robots FROM page WHERE id = ?', [$live]);
|
||||
self::assertSame('leistungen', $nachher['path'], 'Ein Redakteur verschiebt keine veröffentlichte Seite.');
|
||||
self::assertNull($nachher['deleted_at'], 'Ein Redakteur legt keine veröffentlichte Seite in den Papierkorb.');
|
||||
self::assertNull($nachher['footer_area']);
|
||||
self::assertNotSame('noindex', $nachher['robots']);
|
||||
|
||||
// Einen Entwurf, der nie online war, bearbeitet er weiter frei.
|
||||
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
|
||||
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
|
||||
|
||||
// Wer veröffentlichen darf, darf auch das.
|
||||
$client = $this->login('publisher', $client);
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
|
||||
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
|
||||
}
|
||||
|
||||
public function testAScheduledPublicationPublishesTheRevisionThatWasScheduled(): void
|
||||
{
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$id = $pages->createPage('termin', null, 'page', DemoContent::starter('Geprüfter Stand'));
|
||||
$geprueft = $pages->findForEditor($id)['draft_revision_id'];
|
||||
$pages->schedule($id, '2026-01-01 08:00', (int) $pages->findForEditor($id)['version']);
|
||||
// Nach dem Planen speichert jemand einen anderen Entwurf.
|
||||
$pages->saveDraft($id, DemoContent::starter('Später gespeichert'), (int) $pages->findForEditor($id)['version']);
|
||||
self::assertNotSame($geprueft, $pages->findForEditor($id)['draft_revision_id']);
|
||||
|
||||
self::assertSame(['termin'], $pages->publishDue());
|
||||
self::assertSame($geprueft, $this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]), 'Online geht der geplante Stand, nicht der spätere Entwurf.');
|
||||
self::assertNull($this->db->fetchOne('SELECT publish_revision_id FROM page WHERE id = ?', [$id]));
|
||||
}
|
||||
|
||||
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('admin');
|
||||
@@ -499,7 +547,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
{
|
||||
return [
|
||||
'page' => 'home', 'block' => 'formblock', 'website' => '',
|
||||
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
|
||||
'issued' => $issued, 'issuedSignature' => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret'),
|
||||
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
|
||||
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
|
||||
];
|
||||
@@ -611,7 +659,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
*/
|
||||
public static function droppedSubmissions(): iterable
|
||||
{
|
||||
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
|
||||
$sign = static fn (string $issued): string => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret');
|
||||
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
|
||||
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
|
||||
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
|
||||
@@ -650,7 +698,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
self::assertTrue($forms->rateLimited(FormRepository::senderKey('127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testSendersOfOneIpv6NetworkCountTogether(): void
|
||||
{
|
||||
// Ein Anschluss hat ein ganzes /64-Netz; jede Adresse daraus ist derselbe Absender (ADR 116).
|
||||
$a = FormRepository::senderKey('2001:db8:1:2:aaaa::1', 's');
|
||||
self::assertSame($a, FormRepository::senderKey('2001:db8:1:2:ffff:1:2:3', 's'));
|
||||
self::assertNotSame($a, FormRepository::senderKey('2001:db8:1:3::1', 's'));
|
||||
self::assertNotSame(FormRepository::senderKey('192.0.2.1', 's'), FormRepository::senderKey('192.0.2.2', 's'));
|
||||
}
|
||||
|
||||
public function testAStampFromOneFormDoesNotOpenAnother(): void
|
||||
{
|
||||
// Das Siegel gilt für ein Formular; eines von einer anderen Seite lässt sich nicht
|
||||
// wiederverwenden (ADR 116). Der Versand wird still verworfen wie bei jedem Bot.
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
$issued = (string) (time() - 10);
|
||||
$client->request('POST', '/formular/kontakt', [...$this->validBody($issued), 'issuedSignature' => FormRepository::stamp('anderes', (int) $issued, 'isolated-test-secret')]);
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFloodStopsStoringAndTellsTheVisitor(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$form = $forms->findBySlug('kontakt');
|
||||
// Viele verschiedene Absender in der letzten Stunde: die Grenze je Formular greift.
|
||||
for ($i = 0; $i < FormRepository::FORM_RATE_LIMIT; ++$i) {
|
||||
$forms->store($form['id'], ['name' => 'x'], true, 'absender-'.$i);
|
||||
}
|
||||
self::assertTrue($forms->flooded($form['id']));
|
||||
$client = self::createClient();
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
// Sichtbar statt still: ein echter Besucher erfährt, dass er anrufen sollte.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertStringContainsString('Gerade gehen sehr viele Anfragen ein', (string) $client->getResponse()->getContent());
|
||||
self::assertSame(FormRepository::FORM_RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
|
||||
|
||||
@@ -291,7 +291,7 @@ final class FormStepsTest extends TestCase
|
||||
|
||||
$issued = time() - 10;
|
||||
$grund = [
|
||||
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'),
|
||||
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('preisfinder', $issued, 'test-only'),
|
||||
'immobilienart' => 'Grundstück', 'wohnflaeche' => '99', 'ausstattung' => ['Balkon'], 'flaeche' => '640', 'email' => 'x@example.org', 'datenschutz' => 'ja',
|
||||
];
|
||||
$antwort = $this->request('/formular/preisfinder', 'POST', $grund);
|
||||
|
||||
@@ -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/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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user