Compare commits
57
Commits
No files matched your search
+47
-1
@@ -208,6 +208,52 @@
|
||||
"$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": "luximmo",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8108'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8108 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8108
|
||||
},
|
||||
{
|
||||
"name": "luximmo-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8109'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8109 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8109
|
||||
},
|
||||
{
|
||||
"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,15 @@ 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.
|
||||
|
||||
### Luximmo Hausverwaltung (Gaggenau)
|
||||
|
||||
Der Nachbau des Entwurfs `luximmo-website.zip` liegt in `var/luximmo.db` mit Medien in `var/luximmo-media`; die Startkonfiguration `luximmo` nutzt Port 8108, `luximmo-prod` denselben Bestand im Produktionsmodus auf Port 8109. `sh scripts/reset-luximmo.sh [assets-Ordner des Pakets]` holt ein Standbild aus dem Hero-Video (`scripts/build-luximmo-poster.mjs`, Playwright), bereitet Logo, Seitensymbol und Fotos vor (`scripts/build-luximmo-assets.php`; ohne Argument nimmt es `var/luximmo-assets`), baut mit `scripts/seed-luximmo.php` Startseite (Hintergrundvideo im Hero), Verwaltungsanfrage, Impressum, Datenschutz, 404, Menü und die Formulare `kontakt`, `verwaltungsanfrage` und `bewertung` (je drei Schritte mit Bildkarten und Zusammenfassung; Bewertung auf `/immobilienbewertung`) auf, holt Instrument Sans auf den eigenen Server und schreibt `var/luximmo-paket.zip`. Formulare gehen an info@lux-immo.net (abweichend per `LUXIMMO_MAIL`). Passwort für „umut“ in `var/luximmo-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8109 CMS_PATHS=/,/verwaltungsanfrage,/impressum CMS_AUDIT_DIR=reports/lighthouse-luximmo node scripts/audit.mjs`.
|
||||
|
||||
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>
|
||||
Generated
+267
-266
File diff suppressed because it is too large.
Load diff
@@ -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:
|
||||
@@ -41,6 +48,9 @@ services:
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\VideoLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -59,10 +69,19 @@ 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%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\EventSubscriber\ErrorLogSubscriber:
|
||||
arguments:
|
||||
$debug: '%kernel.debug%'
|
||||
App\Controller\SeoController:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
@@ -70,6 +89,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%'
|
||||
@@ -87,6 +110,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%'
|
||||
@@ -115,12 +146,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%'
|
||||
@@ -151,3 +201,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'
|
||||
+168
-1
@@ -1,6 +1,173 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||
|
||||
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
|
||||
|
||||
**Entscheidung:** Drei Einstellungen unter „Einstellungen“: `businessType` aus einer festen Liste von LocalBusiness-Unterarten, `latitude`/`longitude` als Paar, `organisationLogoMediaId` aus der Mediathek. Das Logo ist bewusst getrennt vom sichtbaren Logo im Kopfbereich: Das kann eingebettetes SVG sein und hat dann keine eigene Adresse. Die Liste ist fest, damit kein Tippfehler einen ungültigen Typ erzeugt.
|
||||
|
||||
**Folgen:** Keine Migration: Die Werte liegen im JSON der Einstellungen, fehlende Schlüssel fallen auf die Standardwerte, ausgegeben wird dann genau das Bisherige. Typ und Koordinaten gelten nur für ein lokales Unternehmen mit Anschrift, das Logo auch für eine Organisation. Eine Installation älter als diese Fassung ignoriert die Werte aus einem Inhaltspaket und gibt sie nicht aus.
|
||||
|
||||
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
|
||||
|
||||
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
|
||||
|
||||
**Entscheidung:** Eigene Tabelle `video` und `VideoLibrary` nach dem Vorbild der Dokumente, Dateien als `<id>.mp4`/`<id>.webm` im Medienordner, bis 64 MB, Typ nach den ersten Bytes (MOV abgewiesen). Ausgeliefert unter `/media/<id>.<endung>` mit Range-Anfragen und `default-src 'none'`. Im Dokument stehen Adressen, nicht Kennungen: Baustein `video` (`src`, `srcWebm`, `poster`, `title`, `mode`: `player`/`loop`) und an der Sektion `props.backgroundVideo`/`backgroundVideoWebm`. Der Validator lässt nur Adressen der eigenen Mediathek zu; fremde Videos bleiben Sache von „Externer Inhalt“ mit Einwilligung.
|
||||
|
||||
Schleifen stehen ohne `autoplay` im HTML, mit `preload="none"`. `video.js` startet sie, solange sie zu sehen sind, und zeigt den Knopf zum Anhalten (WCAG 2.2.2). Bei „Bewegung reduzieren“ oder Datensparen bleibt das Standbild, starten lässt es sich von Hand. Das Skript kommt nur auf Seiten mit Schleife; ein Spieler lässt `script-src 'none'` stehen.
|
||||
|
||||
**Folgen:** Kein Umrechnen auf dem Server: Wer zu große Dateien hochlädt, bekommt zu große Dateien, deshalb der Hinweis im Dialog. Die PHP-Grenzen `upload_max_filesize` und `post_max_size` des Hostings gelten zusätzlich. Die Überlagerung einer Sektion ist Teil ihres CSS-Hintergrunds und liegt damit unter dem Video. Website-Pakete von vor v28 bleiben importierbar, die Tabelle gilt dort als leer.
|
||||
|
||||
## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026)
|
||||
|
||||
**Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht.
|
||||
|
||||
**Entscheidung:** Dieselbe Tabelle `installation` mit neuer Spalte `produkt` (Migration 2 der Zentrale; Altbestand wird `kantsites`), dazu `laufzeit`, `mandanten`, `stoerungen`, `speicher`. Eigener Endpunkt `POST /api/kanttresor/melden` (`Api::meldenTresor`) mit demselben Schlüssel-/HMAC-Verfahren wie ADR 123; eine Kennung bleibt an ihr Produkt gebunden (403 sonst). Die Antwort ist nur `{"ok":true}` – nie ein Auftrag, und `Bestand::beauftragen` lehnt KantTresor-Kennungen ab. Übersicht unter `/kanttresor` mit eigenen Spalten und der Kachel „Dienst gestört oder Speicher knapp“ (≥ 90 %). Als neueste Fassung gilt die höchste gemeldete, weil die KantArchiv-Releaseseite nicht öffentlich ist und ihre Tags nicht gepflegt sind. Gegenstück in KantArchiv: `controlplane/zentrale.py`, beat-Task alle 15 min, ab 1.82.0.
|
||||
|
||||
**Folgen:** Die Zentrale muss neu hochgeladen werden; die Datenbank migriert beim ersten Aufruf. „Veraltet“ ist bei KantTresor relativ zum Bestand – ist keine Instanz auf dem neuesten Stand, fällt das nicht auf. KantTresor gibt keine Tür nach außen frei; er sendet nur. Die IP-Adresse wird wie bei KantSites gespeichert.
|
||||
|
||||
## 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)
|
||||
|
||||
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf `/`, `/install` und `/assets/` mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. `/index.php/install` lieferte dagegen 200, auch mit der `.htaccess` – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: `RewriteCond %{REQUEST_FILENAME} !-f` erkennt dort die bereits umgeschriebene `index.php` nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
|
||||
|
||||
**Entscheidung.** `public/.htaccess` setzt `RewriteBase /` und beendet die Umschreibung für `index.php` selbst (`RewriteRule ^index\.php$ - [L]`), wie die `.htaccess` von WordPress. Auf dem Hosting von Umut mit beiden Zeilen bestätigt; welche der beiden allein genügt hätte, ist nicht geprüft. `CmsTest` sichert die Zeilen ab.
|
||||
|
||||
**Folgen.** `RewriteBase /` setzt voraus, dass die Domain auf `public/` zeigt – das war schon Bedingung, eine Installation in einem Unterordner einer Domain ist nicht vorgesehen. Bestehende Installationen bekommen die Zeilen mit dem nächsten Archivupdate, weil der Updater die `.htaccess` des Releases übernimmt (nur eingeschaltete HTTPS-/HSTS-Zeilen werden erhalten). `Options -Indexes -MultiViews` verträgt IONOS: auf huefner-gartenbau.de wieder eingeschaltet, die Seite lädt weiter. Ausgeliefert mit 1.28.1.
|
||||
|
||||
## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
|
||||
|
||||
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
|
||||
|
||||
**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
|
||||
|
||||
**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
|
||||
|
||||
**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
|
||||
|
||||
**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update/<lauf>/alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt.
|
||||
|
||||
**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
|
||||
|
||||
## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+292
@@ -1,5 +1,295 @@
|
||||
# Projektstatus
|
||||
|
||||
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||
|
||||
**Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration.
|
||||
|
||||
**Luximmo:** RealEstateAgent, Koordinaten aus OpenStreetMap (Gebäudetreffer), Logo-Bild für die Daten, Seitentitel „Hausverwaltung & Immobilienverkauf in Gaggenau | LUXIMMO“, Paket neu gebaut.
|
||||
|
||||
**Geprüft:** PHPUnit 687 grün (PHP 8.4, neu `testBusinessTypeLocationAndLogoReachTheStructuredData`), `npm test` 41, Twig-Lint, php-cs-fixer, Felder in der Verwaltung per Playwright (Laden, Logo entfernen und wählen, Speichern, Ausgabe auf der Website), Paketimport. Lighthouse nach dieser Änderung nicht erneut gelaufen.
|
||||
|
||||
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
|
||||
|
||||
## Neue Installation „Luximmo Hausverwaltung“, Gaggenau (06.11.2026)
|
||||
|
||||
**Anlass:** Umut lieferte `luximmo-website.zip` (Onepager der Luximmo UG, Eslem Kurt, Hauptstr. 169, 76571 Gaggenau): „in KantSites nachbauen und wie die anderen Ablagen, gerne etwas ausbauen, und wie bei Immo DEEG ein Formular für Immo auf einer separaten Seite“.
|
||||
|
||||
**Erledigt (nur Inhalt, kein Kerncode):** Datenbank `var/luximmo.db`, Medien `var/luximmo-media`, Bildquellen `var/luximmo-assets`, Startkonfigurationen `luximmo` (8108) und `luximmo-prod` (8109), Inhaltspaket `var/luximmo-paket.zip`. Aufbau nur per `sh scripts/reset-luximmo.sh [assets-Ordner]` (Standbild aus dem Video: `build-luximmo-poster.mjs`; Logo, Seitensymbol aus dem goldenen Zeichen, WebP: `build-luximmo-assets.php`; Inhalte: `seed-luximmo.php`).
|
||||
|
||||
- **Startseite wie die Vorlage:** Kopf mit Menü links, Logo mittig, Knopf rechts; Hero mit dem Video als Hintergrundvideo der Sektion (ADR 125, WebM, Standbild als Hintergrundbild, Anhalten-Knopf oben rechts), gerundet; Intro mit Etikett und drei Kärtchen; drei Leistungen mit Foto; Über uns; Kontakt mit Formular `kontakt`. Farben, Instrument Sans (eigener Server) und Texte übernommen.
|
||||
- **Ausgebaut:** je Leistung „Was dazugehört“ zum Aufklappen, „So einfach wechseln Sie zu uns“ in vier Schritten, häufige Fragen (FAQ-Auszeichnung), Fuß mit Logo, Anschrift und Leistungen; Impressum, Datenschutz, 404.
|
||||
- **Separate Seite `/verwaltungsanfrage`** nach dem Muster des DEEG-Preisfinders: Formular `verwaltungsanfrage` in drei Schritten – Verwaltungsart als Bildkarten (die drei Leistungsfotos) und heutige Verwaltung; Objekt mit Feldern je Art (Einheiten/Stellplätze bei Miet- und WEG-Verwaltung, Beschluss nur bei WEG, Wohnungen/Vermietung nur bei Sondereigentum), Fläche, Baujahr, Ausstattung, Beginn; Lage und Kontakt; Zusammenfassung. Seitenspalte mit Ablauf und Karte der Ansprechpartnerin. Hero-Knopf, Menü „Anfrage“, Ablauf und FAQ verlinken darauf.
|
||||
|
||||
**Geprüft:** Bildschirmfotos 1440 und 390 px (kein waagrechtes Scrollen, keine Konsolenfehler, Klappmenü). Formular per Playwright durchgespielt: WEG blendet Einheiten und Beschluss ein, Sondereigentum-Felder aus; Zusammenfassung vollständig; Absenden mit Erfolgsmeldung und Eingang in `form_submission` (danach per Reset entfernt). `test:links` (4 Seiten, 24 Adressen, kein toter Verweis), `test:browser` gegen 8108 grün (axe, Tastatur, ohne JS; HTML 70 KB, 3 Skripte). Lighthouse (`audit.mjs`, je drei Läufe) auf 8109 für `/`, `/verwaltungsanfrage`, `/impressum`: mobil und Desktop überall 100 (`reports/lighthouse-luximmo`). php-cs-fixer auf den beiden neuen PHP-Skripten angewandt. Nicht geprüft: Mailversand, Safari/iOS (Video nur als WebM, kein ffmpeg zum Umwandeln in MP4), Editor auf diesen Dokumenten.
|
||||
|
||||
**Nachtrag (06.11.2026):** Domain lux-immo.net, E-Mail info@lux-immo.net (Umut). Beide Formulare gehen an info@, die Adresse steht im Kontaktbereich, im Fuß, in der Seitenspalte der Anfrage, in Impressum, Datenschutz und den strukturierten Daten. `test:links` und `test:browser` danach erneut grün.
|
||||
|
||||
**Nachtrag 2 (06.11.2026):** Umut: „Das Menü springt nicht zu den Bereichen“ und „ein Formular ‚Was ist Ihre Immobilie wert?‘“. *Menü:* Das eigene Menü-CSS setzte das Klappmenü auf `z-index:20`, die Klick-daneben-Fläche des Kerns (`.menu-burger[open]>summary::before`, z-index 29) lag darüber und fing jeden Klick ab – auf Tablet und Telefon kam kein Link an. Jetzt `z-index:30` wie im Kern. Per Playwright auf 1440 px, 900 px und iPhone 13 geprüft: Leistungen, Über uns und Bewertung springen bzw. öffnen, das Menü schließt. *Bewertung:* neue Seite `/immobilienbewertung` mit Formular `bewertung` (Objektart Wohnung/Haus/Mehrfamilienhaus/Grundstück als Bildkarten mit goldenen Piktogrammen aus `build-luximmo-assets.php`, Anlass; Eckdaten je Objektart – Grundstück ohne Wohnfläche/Zimmer mit Nutzbarkeit, Mehrfamilienhaus mit Einheiten und Jahresmiete, Wohnung mit Etage; Lage und Kontakt; Zusammenfassung; an info@lux-immo.net). Auf der Startseite ein dunkles Band „Was ist Ihre Immobilie wert?“ mit den vier Objektarten (Anker `#wert`), Menüpunkt „Bewertung“, Link im Fuß, Datenschutz ergänzt. Geprüft: Bedingungen für Grundstück und Mehrfamilienhaus, Absenden mit Eingang (danach per Reset entfernt), `test:links` (5 Seiten, 29 Adressen), `test:browser` grün (HTML 83 KB), Lighthouse `/` und `/immobilienbewertung` mobil und Desktop überall 100.
|
||||
|
||||
**Nachtrag 3 (09.11.2026):** Umuts Korrekturen und der Flyer. Linie unter dem Kopf abgedeckt (Kopfsektion `margin-bottom:-1px`, die Linie zeichnet der Kern an `.site-header`). „So einfach wechseln“ und „Was ist Ihre Immobilie wert?“ sind jetzt Karten in einer normalen Sektion, gleiche Breite wie die Leistungen (bei 2000 px gemessen: 200–1800). Abstand zwischen Sektionen 112 statt 180 px, mobil 64 statt 88 px. Optionsfelder der Bildkarten rund (die Feldregeln gaben ihnen Polster und Mindesthöhe). Vom Flyer: Slogan „Ein Lichtblick für Ihre Immobilie.“ als H1 (ersetzt „Hausverwaltung, die man nicht merkt.“), „Ihr neuer Immobilienpartner vor Ort“, Immobilienverkauf als erste Leistung (Foto: Standbild des Videos, ein eigenes fehlt), WEG-Text, Regionsband, Telefon 07225 5080756, Hauptstraße 169. Das erfundene Zitat auf der Monogramm-Karte ist durch den Slogan ersetzt, „feste Preise je Einheit“ aus der FAQ entfernt. Noch vom Kunden zu bestätigen: „kostenlos“ bei der Bewertung, die Aussage zur Erlaubnis nach § 34c GewO.
|
||||
|
||||
**Livegang-Paket (09.11.2026):** `var/luximmo-paket.zip` (Fassung 1.37.0, 6 Seiten, 26 Mediendateien, Video, 3 Formulare, ohne Konten und Anfragen). In eine frische Testdatenbank importiert (braucht wie jede Installation den Website-Eintrag des Installers; nur migriert scheitert der Import am Fremdschlüssel, ohne etwas zu ändern). Darauf geprüft: alle Seiten, sitemap.xml, llms.txt und Video 200, `test:links` ohne toten Verweis, `test:browser` im Entwicklungsmodus grün (im Produktionsmodus nicht möglich, die Prüfung braucht `X-Kant-Queries`), Lighthouse für `/`, `/immobilienbewertung`, `/verwaltungsanfrage`, `/impressum` mobil und Desktop überall 100. Nicht geprüft: Hosting, Mailversand, Schriftenabruf auf dem Server, Safari/iOS.
|
||||
|
||||
**Logo als SVG (09.11.2026):** Kundendatei `var/luximmo-assets/logo-original.svg`; `seed-luximmo.php` schneidet sie zu (ohne weiße Fläche und IDs) und setzt sie als „Eigenes HTML“ in Kopf und Fuß, die Mediathek nimmt keine SVGs an. Das Seitensymbol bleibt PNG. Tausch künftig im Baustein (nur Administrator). Paket neu gebaut.
|
||||
|
||||
**Offen (von Umut/Kunde):** im Impressum Geschäftsführung, Register, USt-ID, § 34c-Aufsicht und Bildnachweis als „NOCH ZU ERGÄNZEN“, ein Portrait von Eslem Kurt (jetzt Monogramm-Karte), eine MP4-Fassung des Videos für ältere Safari.
|
||||
|
||||
**Nächster Schritt:** Impressum vervollständigen, dann Livegang nach `docs/LIVEGANG.md`.
|
||||
|
||||
## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026)
|
||||
|
||||
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
|
||||
|
||||
**Erledigt (ADR 125, Schema v28, Migration `Version20261104000100`):** `VideoLibrary` und Tabelle `video`; Upload über die Mediathek und den neuen Dialog `openVideos`; Abschnitt „Videos“ in der Mediathek; Auslieferung `/media/<id>.mp4|webm`; Baustein „Video“ (Spieler oder Schleife, Zweitformat WebM, Standbild, Titel); Hintergrundvideo samt WebM an der Sektion; `video.js` startet Schleifen nur sichtbar, mit Knopf zum Anhalten, ohne Start bei „Bewegung reduzieren“ und Datensparen. Website-Paket nimmt Tabelle und Dateien mit, ältere Pakete bleiben importierbar.
|
||||
|
||||
**Geprüft:** PHPUnit 686 Tests unter PHP 8.3, davon nur die sechs `UpdaterTest`-Fälle rot, die in der Testumgebung auch ohne diese Änderung scheitern (kein Git-Verlauf der Arbeitskopie). Neu: `testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground`. 41 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung. Playwright (Chromium) in einer isolierten Installation: Upload beider Formate, Auswahl im Editor, Vorschau, Veröffentlichen, WebM läuft, füllt die Sektion, Knopf hält an, Reduzieren startet nichts und lädt nichts, Range-Antwort 206, keine CSP- und Konsolenfehler.
|
||||
|
||||
**Nicht geprüft:** Safari und echtes iOS-Gerät, H.264 im Testbrowser (Playwright-Chromium spielt nur WebM), Lighthouse mit Video, Upload auf dem Hosting (PHP-Limits).
|
||||
|
||||
**Durchsicht vor dem Release (05.11.2026):** CSP (`default-src 'self'` deckt die Videos), Sicherung, Website-Paket, Auslieferung mit Range und Schema-Migration passen. Nachgebessert: „Video“, „Video anhalten“ und „Video abspielen“ fehlten im Wörterbuch von `kant_t` – auf `/en/`- und türkischen Seiten stand der Knopf deutsch; jetzt Englisch und Türkisch (Französisch fällt wie sonst auf Englisch zurück). `video.js` überspringt ein `data-video-loop` ohne Knopf (etwa aus „Eigenes HTML“), statt mit einem Fehler alle folgenden Videos stehen zu lassen. Im Design-Panel stehen beim Video Größe und Abstand vorn statt der Textgruppe.
|
||||
|
||||
**Geprüft:** PHPUnit 686/686 grün unter PHP 8.4 (auch `UpdaterTest`) und 8.2 (dort nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings`); php-cs-fixer ohne Befund, `lint:twig`, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt). Lokale `var/cms.db` auf `Version20261104000100` migriert.
|
||||
|
||||
**Release:** Tag `v1.37.0` auf `main` (Zweig `videos` per Fast-Forward übernommen), Releaseeintrag mit Zip (3685 Dateien, 5,7 MB), `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (ec59d058…), `releases/latest` meldet v1.37.0.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale auf 1.37.0 aktualisieren, dann auf kant-it.de ein Video hochladen und in der Hero-Sektion als Hintergrundvideo wählen; dabei Safari/iOS und Lighthouse mit Video prüfen.
|
||||
|
||||
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-11.md`, nicht in Git): `symfony/mime` 7.4.19 betroffen von CVE-2026-45070 (E-Mail-Header-Injection über Parameternamen, behoben ab 7.4.22), in `main` und in v1.36.1. KantSites baut keine MIME-Parameternamen aus Eingaben, Ausnutzbarkeit gering. Umut: „Ja, Update + Release“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.x → 7.4.22 (mime, mailer, framework-bundle, http-kernel, security-*, console, yaml, rate-limiter u. a.), Polyfills 1.43.3, masterminds/html5 2.12.0. `npm audit fix` für die Entwicklungswerkzeuge (zod 3.25 → 4.6.7 unter lighthouse/puppeteer-core). `composer.json` unverändert, kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` und `npm audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4; unter 8.2 nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (vorbestehend). php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt).
|
||||
|
||||
**Release:** Tag `v1.36.2`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (3cf4e8c9…), `releases/latest` meldet v1.36.2. Im Archiv steckt `symfony/mime` v7.4.22.
|
||||
|
||||
**Weiter offen aus dem Check:** HSTS auf den Kundenseiten einschalten (System → HSTS); PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.36.2.
|
||||
|
||||
## Zentrale: Reiter KantTresor (27.10.2026)
|
||||
|
||||
**Anlass:** Umut: KantTresor soll sich in der Zentrale melden, eigener Reiter; „was wir nicht brauchen ist Aktualisierung per Knopfdruck“.
|
||||
|
||||
**Erledigt:** `zentrale/`: Migration 2 (`produkt`, `laufzeit`, `mandanten`, `stoerungen`, `speicher`; Altbestand = KantSites), Endpunkt `POST /api/kanttresor/melden`, Reiter KantSites/KantTresor, Übersicht `/kanttresor` mit Kacheln (inkl. „Dienst gestört oder Speicher knapp“), Live-Suche, Detailseite ohne Update- und Auftragsteil, serverseitige Ablehnung von Aufträgen für KantTresor. ADR 124, `zentrale/README.md`. KantArchiv (eigenes Repo): `controlplane/zentrale.py`, Modell `ZentraleMeldung` (Migration 0016), beat-Task `controlplane.zentrale_melden` alle 15 min, Einstellungen `ZENTRALE_URL`/`ZENTRALE_ADRESSE`/`ZENTRALE_POLL_SECONDS`, Tests – dort als 1.82.0 committet und gepusht (fc2019b, b261658).
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` 28/28 (11 neue zu KantTresor). KantArchiv: `tests.test_zentrale` + `test_manage_gui` 24/24, ruff, mypy, `migrate_all` im Dev-Stack. Ende-zu-Ende: echte Meldung aus dem KantTresor-Container per HTTP an die lokale Zentrale (200, Antwortunterschrift gültig). Upgrade einer Zentrale-DB im Stand 1. Browser (Port 8115): Reiter, Filter, Live-Suche, Zurücksetzen bleibt auf `/kanttresor`, Detailseite, Notiz, untergeschobener Update-Auftrag wird abgelehnt, KantSites-Reiter unverändert mit Update-Knöpfen, 375 px ohne Querscrollen, keine Konsolenfehler. Demo-DB wieder gelöscht. Nicht geprüft: Live-Zentrale, Meldung einer echten KantTresor-Produktion über das Internet.
|
||||
|
||||
**Fund unterwegs:** Der Dev-Stack von KantArchiv hat `host.docker.internal` in `DJANGO_ALLOWED_HOSTS` und hätte sich sonst an die echte Zentrale gemeldet. Jetzt gesperrt über `.internal` und `DJANGO_DEBUG=True`; vor dem ersten Takt gestoppt, es ging nichts raus.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (`config.php` und `var/` bleiben). KantTresor-Instanzen mit `./update.sh` auf 1.82.0 heben; nach spätestens 15 min erscheinen sie im Reiter. KantSites-Änderungen sind noch nicht committet.
|
||||
|
||||
## Zentrale: Warnung bei unsicherem PHP (22.10.2026)
|
||||
|
||||
**Anlass:** Kritische PHP-Lücke „php2shell“ (CVE-2026-109618/-109619, CVSS 9.2, Patches vom 21.10.2026: 8.2.35, 8.3.36, 8.4.27, 8.5.12). Umut: Warnung „bei sitesviewer einbauen, nicht in KantSites selbst“.
|
||||
|
||||
**Erledigt:** Nur `zentrale/`, kein Eingriff ins CMS, keine Schemaänderung. `Bestand::PHP_SICHER` (erste sichere Fassung je Zweig) und `PHP_ALT_BIS` (8.1 und älter ohne Updates); `Bestand::phpLuecke()` liefert den Grund oder null. Unbekannte Angaben und neuere Zweige (8.6-Vorabfassungen) gelten nicht als unsicher. Übersicht: neue Kachel/Filter „PHP mit Sicherheitslücke“, rote Pille „PHP x.y.z unsicher“ in der Spalte Zustand, Suche findet auch die PHP-Fassung. Detailseite: Pille plus Hinweis, dass das Update beim Hoster liegt. Bei der nächsten PHP-Sicherheitsmeldung `PHP_SICHER` nachziehen.
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` grün (drei neue Prüfungen zur PHP-Regel). Lokal im Browser (Port 8115, drei Demo-Installationen mit 8.4.22, 8.4.27, 8.1.30): Kachel zählt 2, Filter zeigt genau die zwei, Detailseite mit Hinweis. Demo-Datenbank wieder gelöscht. Nicht geprüft: Mobilansicht, Live-Zentrale.
|
||||
|
||||
**Bekannte Grenze:** Distributionen mit zurückportierten Patches (z. B. Debian/Ubuntu) melden weiter die alte Versionsnummer und erscheinen dann fälschlich als unsicher. Installationen vor 1.36.0 melden kein PHP.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`, neu gebaut); `config.php` und `var/` auf dem Server bleiben. PHP bei allen Hostings auf 8.4.27 bringen.
|
||||
|
||||
## Symfony-Patchupdate, Release 1.36.1 (14.10.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-10.md`, nicht in Git): keine Sicherheitslücken, aber Patch-Rückstand bei Symfony. Umut: „dann mach neueste symfony version dann kann ich den updater mal testen“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.15–7.4.18 → 7.4.19–7.4.21 (framework-bundle, http-kernel, security-*, twig-bundle, console, yaml u. a.), Polyfills 1.43.0, twig/twig 3.28 → 3.30, doctrine/lexer 3.0.3. Bewusst bei der LTS-Linie 7.4 geblieben (Sicherheitssupport bis 11/2029); 8.x wäre ein Major-Sprung mit PHP-8.4-Pflicht. `composer.json` unverändert, Plattform weiter PHP 8.2.12. Kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4 und 8.2; unter 8.2 eine Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (`file_get_contents` auf eine von `git checkout` entfernte `.env`, `Updater.php:468`), die mit der alten `composer.lock` genauso auftritt – vorbestehend. php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün (erster Lauf hing einmal beim Warten auf „Veröffentlicht“, zweiter Lauf vollständig grün), `test:browser` gegen 8080 grün (zwei Kontrastbefunde aus im Editor gesetzten Farben auf `/grundlagen`, Inhalt, nicht Code).
|
||||
|
||||
**Release:** Tag `v1.36.1`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (fe286f49…), `releases/latest` meldet v1.36.1.
|
||||
|
||||
**Nächster Schritt:** Umut testet den Updater: eine Installation auf 1.36.0 per „System“ oder über die Zentrale auf 1.36.1 heben. Weitere Punkte aus dem Sicherheitscheck: HSTS auf den Kundenseiten einschalten (System → HSTS), PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
## Zentrale: Suche, Filter und Seiten (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du Suche und Pagination dort einbauen bei Zentrale“.
|
||||
|
||||
**Erledigt:** Übersicht in `zentrale/` mit Suche (Domain, frühere Domain, Kunde/Notiz, Fassung, IP), Zählerkacheln als Filter (mit Markierung und `aria-current`), Seiten zu je 25 Einträgen mit Zurück/Weiter und Auslassungen, Trefferzeile als Statusmeldung, eigener Leerzustand „Nichts gefunden“. Domains natürlich sortiert, „www.“ zählt nicht. „Alle aktualisieren“ nennt höchstens acht Domains und „und N weitere“. Kein Eingriff ins CMS, keine Schemaänderung.
|
||||
|
||||
**Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün.
|
||||
|
||||
**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet.
|
||||
|
||||
## 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.
|
||||
|
||||
**Release:** sites.kant-it.de ist online (Umut; `/api/melden` antwortet, `config.php` nicht abrufbar). Fassung 1.36.0 am 14.10.2026 veröffentlicht: Tag `v1.36.0`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (688a0689…), `releases/latest` meldet v1.36.0.
|
||||
|
||||
**Nächster Schritt:** kant-it.de und die übrigen Live-Installationen unter „System“ auf 1.36.0 heben, erste Meldung in der Zentrale prüfen, das erste Fern-Update mit der nächsten Fassung ausprobieren.
|
||||
|
||||
## 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.
|
||||
|
||||
**Ursache und Änderung (ADR 114):** PHP 8.4.26 lief (`test.php`), `/index.php/install` lieferte 200, nur umgeschriebene Adressen scheiterten – Umschreibschleife in `public/.htaccess`. Neu im Kern: `RewriteBase /` und `RewriteRule ^index\.php$ - [L]` vor der `!-f`-Regel; Test in `CmsTest::testEveryPublicAnswerCarriesItsProtectiveHeaders`.
|
||||
|
||||
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.28.1`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
|
||||
|
||||
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
|
||||
|
||||
## Hüfner: Änderungen aus dem Kundentermin (12.10.2026)
|
||||
|
||||
**Anlass:** Umuts handschriftliche Notizen vom Hüfner-Termin plus Antworten auf Rückfragen. Nur Inhalt und Seed-Skript, kein Kerncode.
|
||||
|
||||
**Erledigt (`scripts/seed-huefner.php`, `build-huefner-assets.php`, `reset-huefner.sh`):** Alle Grüntöne auf das Baumgrün des Logos (#067F30) gedreht, „Premium“ und Hervorhebungen in Baumgrün, kleine Schrift auf Dunkel weiß statt hellgrün (Kontrast). Das „💯“ als Umuts Emoji-Bild überall, wo „100“ steht, im Text (`<mark>100</mark>`: die Zahl bleibt lesbar für Vorleser und KI, sichtbar ist das Bild in Schriftgröße, geschütztes Leerzeichen vor „%“); Quelle `100-emoji.png` in den Unterlagen. „Miteinander. 💯 %.“-Abzeichen jetzt dunkel statt rot. Freistellungsbescheinigungen (§ 13b UStG, § 48b EStG) als PDF bei „Planung & Angebot“ und im Fuß, PQ-Bescheinigung unter „Über uns“ und im Fuß, Galabau-Signet und PQ-Logo. Imagevideo (YouTube, youtube-nocookie) als „Externer Inhalt“ – dafür jetzt Einwilligungshinweis (Ecke) und angepasste Datenschutzerklärung. Google-Bewertungen unter „100 % Qualität. 100 % Zufriedenheit.“ mit 4,9 / über 150 als Beleg, drei echte Rezensionen (gekürzt, Namen abgekürzt), Knopf zum Eintrag. 48-h-Angebotsgarantie in Ablauf und Kontakt, „Leistung“ aus dem Formular, Chips verlinken zum Formular, WhatsApp (0176 30387990) in Kontakt und Karriere, 25 Jahre / 7.000+ Projekte, „Bagger bis 15 t“ raus. Karriere: Facharbeiter und LKW-Fahrer C/CE (wie Instagram) als zwei gleich hohe, aufklappbare Karten (details/summary, SVG-Zeichnungen), „Ein Anruf genügt!“. Neue Seite `/alpakas` mit Umuts Text, Ergänzungen, Galerie und Plakat; „Alpakas“ im Menü, „Ablauf“ aus dem Menü raus. `browser-check.mjs`: PDFs zählen beim „erster interner Link“-Klick nicht mit.
|
||||
|
||||
**Geprüft:** `test:browser` auf 8102 für `/`, `/alpakas`, `/datenschutz`, `/impressum` grün (HTML Startseite 148.905 B von 150.000 – knapp). Lighthouse Produktion (`reports/lighthouse-huefner-termin`): `/` mobil 96, Desktop 99; `/alpakas` mobil 95, Desktop 100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing überall 100. Im Browser: Video lädt erst nach Einwilligung, Stellenkarten klappen auf, Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Bekannt / offen:** Die Freistellungsbescheinigung § 13b gilt nur bis 31.12.2026 – dann neue Datei in die Unterlagen und neu aufbauen. Die Alpaka-Videos (mp4) sind nicht eingebunden (keine Videoablage im Kern). Offen von Umut: Logos der Sponsoren, „Über uns – mehr lesen“, ggf. Stellenbilder aus ChatGPT statt der SVG-Zeichnungen, genauer Verbandsname. Die Sprachtest-Kopie (`var/browser-tests/lang`) teilt `var/huefner-media` und hat nach dem Neuaufbau keine passenden Bilder mehr. Sicherung vorher: `var/backups/huefner-vor-termin-1210/`. Nebenbefund Kern: `CustomCode::parse` lehnt `url('…')` mit Anführungszeichen immer ab (optionales Anführungszeichen im regulären Ausdruck backtrackt).
|
||||
|
||||
**Nächster Schritt:** Umuts Durchsicht; fehlende Punkte nachliefern lassen.
|
||||
|
||||
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
|
||||
|
||||
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
|
||||
|
||||
**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.28.0`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Bekannt:** Der neue Ablauf greift erst bei Updates, die von 1.28.0 ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig).
|
||||
|
||||
**Nächster Schritt:** kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren.
|
||||
|
||||
## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“
|
||||
@@ -14,6 +304,8 @@
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.27.1`, Release-Eintrag mit Archiv (3607 Dateien, 5,3 MB, gebaut aus `var/release-source-1.27.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Die lokalen Installationen laufen aus diesem Repository und haben 1.27.1 (keine Migration).
|
||||
|
||||
**Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren.
|
||||
|
||||
## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026)
|
||||
|
||||
@@ -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,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261104000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Videos in der Mediathek: fuer den Video-Baustein und den Hintergrund einer Sektion.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle wie bei den Dokumenten: jede Stelle, die ein Bild sucht, erwartet
|
||||
// Varianten mit Breite und Hoehe.
|
||||
$video = $schema->createTable('video');
|
||||
$video->addColumn('id', 'string', ['length' => 32]);
|
||||
$video->addColumn('title', 'string', ['length' => 160]);
|
||||
$video->addColumn('mime', 'string', ['length' => 16]);
|
||||
$video->addColumn('bytes', 'integer');
|
||||
$video->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$video->addColumn('version', 'integer', ['default' => 1]);
|
||||
$video->addColumn('created_at', 'datetime_immutable');
|
||||
$video->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('video');
|
||||
}
|
||||
}
|
||||
Generated
+47
-47
@@ -213,13 +213,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
|
||||
"integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"modern-tar": "^0.8.6",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -242,9 +242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||
"integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -308,9 +308,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -377,9 +377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/yargs": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -608,14 +608,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"version": "157.0.8090-0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
|
||||
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
@@ -812,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1029,9 +1029,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
|
||||
"integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1100,34 +1100,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.10.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
|
||||
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
|
||||
"version": "25.16.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
|
||||
"integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
"@puppeteer/browsers": "3.2.6",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1704951",
|
||||
"typed-query-selector": "^2.12.3",
|
||||
"webdriver-bidi-protocol": "1.0.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
||||
"version": "0.0.1666840",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
|
||||
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
|
||||
"version": "0.0.1704951",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
|
||||
"integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1285,9 +1285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
|
||||
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1306,9 +1306,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
|
||||
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -1412,9 +1412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
||||
@@ -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>
|
||||
+25
-7
@@ -2,18 +2,32 @@ DirectoryIndex index.php
|
||||
Options -Indexes -MultiViews
|
||||
|
||||
# Alles, was keine Datei ist, beantwortet PHP.
|
||||
#
|
||||
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
|
||||
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
|
||||
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
|
||||
# Absicherung steht in der .htaccess von WordPress.
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
RewriteBase /
|
||||
|
||||
# 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.
|
||||
@@ -58,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.
|
||||
|
||||
+111
-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 ---------------------------- */
|
||||
|
||||
@@ -768,6 +843,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.logo-preview{object-fit:contain}
|
||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||
.favicon-editor[hidden]{display:none}
|
||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||
@@ -825,6 +901,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 }
|
||||
}
|
||||
@@ -1049,3 +1128,34 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
|
||||
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
|
||||
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
|
||||
@keyframes update-dreh { to { transform: rotate(360deg) } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
|
||||
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
|
||||
.update-neu h3 { margin: 0; font-size: 17px }
|
||||
.update-neu .muted { margin: 0 }
|
||||
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
|
||||
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
|
||||
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
|
||||
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
|
||||
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
|
||||
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
|
||||
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
|
||||
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
|
||||
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
|
||||
.update-schritt.is-offen { color: #8f9e96 }
|
||||
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
|
||||
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
|
||||
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
|
||||
.update-meldung.is-ok { color: #9fdcb5 }
|
||||
.update-meldung.is-fehler { color: #ffb4a6 }
|
||||
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
|
||||
.update-letztes { margin: 0 0 12px }
|
||||
.update-manual { margin-top: 6px }
|
||||
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
|
||||
.update-dialog::backdrop { background: #1f2d2766 }
|
||||
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
|
||||
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -513,7 +513,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed', 'video'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', video: 'Video', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', video: '▶', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
|
||||
*/
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'video', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
@@ -132,6 +132,8 @@ export function createBlock(type) {
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
// Leer eingefuegt wie „Eigenes HTML“: auf der Website steht nichts, bis ein Video gewaehlt ist.
|
||||
if (type === 'video') block.props = { src: '', mode: 'player', title: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -197,6 +199,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'video') upgradeDocument(document, 28);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
@@ -348,7 +351,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 27;
|
||||
export const SCHEMA_VERSION = 28;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
@@ -692,7 +695,7 @@ export function relevantProperty(block, key, property) {
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'video', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
|
||||
+55
-1
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, openVideos, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
@@ -549,6 +549,13 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'video') {
|
||||
videoChoice(panel, block, 'src', 'Video', 'MP4 mit H.264 spielt jeder Browser ab.');
|
||||
videoChoice(panel, block, 'srcWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
|
||||
posterChoice(panel, block);
|
||||
field('Verhalten', 'mode', block.props.mode || 'player', { choices: { player: 'Spieler mit Steuerung', loop: 'Schleife ohne Ton' }, help: 'Die Schleife startet von selbst, sobald das Video zu sehen ist, und läuft nur stumm. Wer „Bewegung reduzieren“ eingestellt hat, sieht das Standbild.' });
|
||||
field('Titel', 'title', block.props.title || '', { maxLength: 160, help: 'Worum es im Video geht. Screenreader lesen das vor. Bei einer rein dekorativen Schleife leer lassen.' });
|
||||
}
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
@@ -671,6 +678,8 @@ function renderContent(block, panel) {
|
||||
panel.append(images);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
videoChoice(panel, block, 'backgroundVideo', 'Hintergrundvideo', 'Läuft stumm in der Schleife hinter dem Inhalt. Das Hintergrundbild unter Design bleibt das Standbild, bis das Video läuft, und für alle, die keine Bewegung möchten. Eine Überlagerung liegt nicht über dem Video.');
|
||||
videoChoice(panel, block, 'backgroundVideoWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
}
|
||||
@@ -866,6 +875,51 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Videofeld im Eigenschaftenfeld. Im Dokument steht die Adresse der Datei. */
|
||||
function videoChoice(panel, block, key, labelText, helpText, only = '') {
|
||||
const value = block.props?.[key] || '';
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = labelText; wrap.append(label);
|
||||
const set = src => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 28);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props ??= {};
|
||||
if (src) current.props[key] = src; else delete current.props[key];
|
||||
});
|
||||
renderProperties(); changed();
|
||||
};
|
||||
if (value) { const name = document.createElement('code'); name.textContent = value; wrap.append(name); }
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Video wählen' : 'Video wählen oder hochladen';
|
||||
pick.addEventListener('click', () => openVideos({ only, onChoose: item => set(item.src) }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Video entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = helpText; wrap.append(help);
|
||||
panel.append(wrap);
|
||||
}
|
||||
|
||||
/** Das Standbild eines Videos: ein Bild der Mediathek, in einer Breite, die für ein Video reicht. */
|
||||
function posterChoice(panel, block) {
|
||||
const value = block.props?.poster || '';
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = 'Standbild (optional)'; wrap.append(label);
|
||||
const preview = createMediaPreview({ src: value }); wrap.append(preview.element);
|
||||
const set = src => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
if (src) current.props.poster = src; else delete current.props.poster;
|
||||
});
|
||||
renderProperties(); changed();
|
||||
};
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild wählen';
|
||||
pick.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) set((item.variants.find(variant => variant.width >= 1600) ?? item.variants.at(-1)).src); } }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
|
||||
@@ -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,15 @@
|
||||
@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}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-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:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-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}
|
||||
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.
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';
|
||||
|
||||
@@ -58,6 +58,7 @@ export const PREVIEWS = {
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
video: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10 5.4l5 2.6-5 2.6z"/>',
|
||||
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* The logo for the structured data: one image from the media library, shown as a small preview.
|
||||
* It is saved with the form, like every other value on this screen.
|
||||
*/
|
||||
const field = document.querySelector('[data-logo-field]');
|
||||
if (field) {
|
||||
const input = field.querySelector('[name="organisationLogoMediaId"]');
|
||||
const current = field.querySelector('.favicon-current');
|
||||
const choose = field.querySelector('[data-logo-choose]');
|
||||
const remove = field.querySelector('[data-logo-remove]');
|
||||
|
||||
const show = (label, src = '') => {
|
||||
current.replaceChildren();
|
||||
if (src) {
|
||||
const image = new Image(64, 64);
|
||||
image.className = 'favicon-preview logo-preview';
|
||||
image.alt = '';
|
||||
image.src = src;
|
||||
current.append(image);
|
||||
}
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
current.append(text);
|
||||
choose.textContent = input.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !input.value;
|
||||
};
|
||||
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: items => {
|
||||
if (!items.length) return;
|
||||
input.value = items[0].id;
|
||||
show(`„${items[0].title}“ – wird beim Speichern übernommen.`, items[0].variants[0].src);
|
||||
choose.focus();
|
||||
} }));
|
||||
remove.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
show('Kein Logo gewählt – wird beim Speichern übernommen.');
|
||||
choose.focus();
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'video/mp4', 'video/webm'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
|
||||
@@ -206,4 +206,69 @@ export function openDocuments({ onChoose } = {}) {
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Video aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Wie bei den PDFs ein eigener, schmaler Dialog. Mit only: 'video/webm' bietet er nur dieses Format
|
||||
* an, für das Zweitformat neben dem MP4.
|
||||
*/
|
||||
export function openVideos({ onChoose, only = '' } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'video-dialog-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">VIDEOS</p><h2 id="video-dialog-title">Video wählen</h2></div><button class="secondary" id="video-close">Schließen</button></div>
|
||||
<p class="panel-hint">MP4 (H.264) oder WebM bis 64 MB. Das Video wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar. Kleine Dateien laden schneller – ein Hintergrund sollte nur wenige MB haben.</p>
|
||||
<div class="media-tools"><div><label for="video-search">Videos suchen</label><input id="video-search" type="search" placeholder="Titel"></div><div><label for="video-upload">Video hochladen</label><input id="video-upload" type="file" accept="video/mp4,video/webm,.mp4,.webm" disabled></div></div>
|
||||
<p id="video-status" role="status" aria-live="polite">Wird geladen …</p><p id="video-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="video-list" class="document-list" role="group" aria-label="Video auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="video-insert" class="primary" disabled>Video übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#video-error').textContent = message; $('#video-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/videos';
|
||||
const fits = item => !only || item.mime === only;
|
||||
function render() {
|
||||
const query = $('#video-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => fits(item) && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#video-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', item.mime === 'video/webm' ? 'WEBM' : 'MP4', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#video-insert').click(); });
|
||||
$('#video-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#video-list').append(element('p', items.some(fits) ? 'Kein Video mit diesem Titel.' : 'Noch keine passenden Videos. Lade oben eines hoch.', 'muted'));
|
||||
$('#video-status').textContent = `${items.filter(fits).length} Videos${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#video-insert').disabled = !chosen;
|
||||
}
|
||||
$('#video-search').addEventListener('input', render);
|
||||
$('#video-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#video-upload').addEventListener('change', async () => {
|
||||
const file = $('#video-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 64 * 1024 * 1024) { error(`${file.name}: größer als 64 MB`); $('#video-upload').value = ''; return; }
|
||||
$('#video-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = fits(data.item) ? data.item : null;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#video-upload').value = ''; render();
|
||||
});
|
||||
$('#video-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('Videos konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#video-upload').disabled = false; render();
|
||||
}).catch(err => { $('#video-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -23,3 +23,5 @@
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
/* Die Vorschau läuft ohne Skript; dann blendet Chrome bei jedem Video die Steuerung ein. */
|
||||
.section-video::-webkit-media-controls{display:none!important}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -241,6 +241,16 @@ body.consent-open{padding-bottom:320px}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
|
||||
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
|
||||
.block-video{position:relative;margin:0;max-width:100%}
|
||||
.video-media{display:block;width:100%;height:auto;border-radius:10px}
|
||||
.has-video{position:relative;overflow:hidden}
|
||||
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
|
||||
.has-video>.section-inner{position:relative;z-index:1}
|
||||
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.video-toggle::before{content:"❚❚"}
|
||||
.video-toggle.is-paused::before{content:"▶"}
|
||||
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.video-toggle[hidden]{display:none}
|
||||
.block-html{max-width:100%}
|
||||
.block-html img,.block-html video,.block-html table{max-width:100%}
|
||||
.block-html table{border-collapse:collapse}
|
||||
@@ -264,6 +274,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 +487,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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
|
||||
*
|
||||
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
|
||||
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
|
||||
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
|
||||
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
|
||||
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
|
||||
* und die Seite sagt es.
|
||||
*
|
||||
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
|
||||
*/
|
||||
const karte = document.querySelector('[data-update]');
|
||||
if (karte) {
|
||||
const live = karte.querySelector('[data-update-live]');
|
||||
const dialog = document.getElementById('update-dialog');
|
||||
const csrf = karte.dataset.csrf;
|
||||
const SCHRITTE = [
|
||||
['sichern', 'Daten sichern'],
|
||||
['laden', 'Fassung laden und prüfen'],
|
||||
['umschalten', 'Umschalten'],
|
||||
['migrieren', 'Datenbank anpassen'],
|
||||
['pruefen', 'Funktion prüfen'],
|
||||
];
|
||||
let ziel = null, timer = null, laeuft = false;
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const abfragen = async url => {
|
||||
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
|
||||
if (!antwort.ok) throw new Error(String(antwort.status));
|
||||
return antwort.json();
|
||||
};
|
||||
const senden = async (url, daten) => {
|
||||
const antwort = await fetch(url, {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
|
||||
body: new URLSearchParams(daten),
|
||||
});
|
||||
const text = await antwort.text();
|
||||
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
|
||||
};
|
||||
|
||||
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
|
||||
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
|
||||
function notizen(text) {
|
||||
const box = el('div', 'update-notizen');
|
||||
text.split('\n').forEach((zeile, index) => {
|
||||
if (index) box.append(document.createElement('br'));
|
||||
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
|
||||
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
|
||||
});
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Prüfen
|
||||
function warten(text) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren(el('p', 'update-wait', text));
|
||||
live.firstChild.prepend(el('span', 'update-spinner'));
|
||||
}
|
||||
|
||||
function zeigeErgebnis(daten) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren();
|
||||
const letzter = daten.status;
|
||||
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
|
||||
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
|
||||
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
|
||||
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
|
||||
live.append(box);
|
||||
}
|
||||
if (daten.fehler) {
|
||||
const zeile = el('p', 'notice error', daten.fehler + ' ');
|
||||
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
|
||||
nochmal.addEventListener('click', pruefen);
|
||||
zeile.append(nochmal); live.append(zeile);
|
||||
return;
|
||||
}
|
||||
if (!daten.neu) {
|
||||
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
|
||||
return;
|
||||
}
|
||||
ziel = daten.neu.version;
|
||||
const box = el('div', 'update-neu');
|
||||
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
|
||||
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
|
||||
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
|
||||
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
|
||||
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
|
||||
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
|
||||
box.append(knopf);
|
||||
live.append(box);
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
warten('Frage nach neuen Fassungen …');
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.pruefen);
|
||||
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
|
||||
zeigeErgebnis(daten);
|
||||
} catch {
|
||||
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Lauf
|
||||
function zeigeLauf(status, server = true) {
|
||||
live.hidden = false;
|
||||
const box = el('div', 'update-lauf');
|
||||
const kopf = el('p', 'update-lauf-kopf');
|
||||
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
|
||||
if (!ende) kopf.append(el('span', 'update-spinner'));
|
||||
kopf.append(ende
|
||||
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
|
||||
: `Update auf ${status.auf || ziel || ''} läuft`);
|
||||
box.append(kopf);
|
||||
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
|
||||
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
|
||||
const aktuell = gescheitert
|
||||
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
|
||||
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
|
||||
const liste = el('ol', 'update-schritte');
|
||||
SCHRITTE.forEach(([phase, text], index) => {
|
||||
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
|
||||
if (gescheitert && index === aktuell) zustand = 'fehler';
|
||||
const punkt = el('li', `update-schritt is-${zustand}`);
|
||||
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
|
||||
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
|
||||
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
|
||||
liste.append(punkt);
|
||||
});
|
||||
box.append(liste);
|
||||
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
|
||||
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
|
||||
box.append(meldung);
|
||||
if (ende) {
|
||||
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
|
||||
weiter.addEventListener('click', () => location.reload());
|
||||
box.append(weiter);
|
||||
}
|
||||
live.replaceChildren(box);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.status);
|
||||
if (daten.status) zeigeLauf(daten.status);
|
||||
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
|
||||
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
|
||||
} catch {
|
||||
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
|
||||
const letzter = live.querySelector('.update-meldung');
|
||||
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
|
||||
}
|
||||
}
|
||||
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
|
||||
function stoppen() { clearInterval(timer); timer = null; }
|
||||
|
||||
async function starten() {
|
||||
dialog.close();
|
||||
laeuft = true;
|
||||
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
|
||||
verfolgen();
|
||||
try {
|
||||
const start = await senden(karte.dataset.starten, { version: ziel });
|
||||
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
|
||||
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (ende.ok) setTimeout(() => location.reload(), 2500);
|
||||
} catch (fehler) {
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
|
||||
}
|
||||
}
|
||||
|
||||
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
|
||||
pruefen();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const FIELDS = {
|
||||
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
||||
video: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
};
|
||||
|
||||
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Schleifenvideos: im Hintergrund einer Sektion oder im Video-Baustein.
|
||||
*
|
||||
* Abgespielt wird erst von hier aus, nicht mit autoplay im HTML. So lädt die Seite zuerst ihren
|
||||
* Inhalt, das Video folgt danach (preload="none"), und es läuft nur, solange es zu sehen ist.
|
||||
* Wer „Bewegung reduzieren“ eingestellt hat oder Daten sparen will, bekommt das Standbild und kann
|
||||
* das Video selbst starten. Der Knopf gehört dazu: Bewegung, die länger als fünf Sekunden läuft,
|
||||
* muss sich anhalten lassen (WCAG 2.2.2). Ohne Skript bleibt das Standbild.
|
||||
*/
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const saving = navigator.connection?.saveData === true;
|
||||
|
||||
for (const video of document.querySelectorAll('[data-video-loop]')) {
|
||||
const toggle = video.parentElement.querySelector(':scope > .video-toggle');
|
||||
// Ein data-video-loop aus „Eigenes HTML“ hat keinen Knopf; ohne ihn bleibt es beim Standbild.
|
||||
if (!toggle) continue;
|
||||
let wanted = !reduced.matches && !saving;
|
||||
let visible = false;
|
||||
const paint = () => {
|
||||
toggle.classList.toggle('is-paused', !wanted);
|
||||
toggle.setAttribute('aria-label', wanted ? toggle.dataset.pause : toggle.dataset.play);
|
||||
};
|
||||
const sync = () => {
|
||||
if (!wanted || !visible) { video.pause(); return; }
|
||||
video.play().catch(error => {
|
||||
// Ein Anhalten mitten im Starten ist kein Fehler. Verweigert der Browser das Abspielen,
|
||||
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit.
|
||||
if (error.name === 'NotAllowedError') { wanted = false; paint(); }
|
||||
});
|
||||
};
|
||||
toggle.hidden = false;
|
||||
toggle.addEventListener('click', () => { wanted = !wanted; paint(); sync(); });
|
||||
reduced.addEventListener('change', () => { if (reduced.matches) { wanted = false; paint(); sync(); } });
|
||||
new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(video);
|
||||
paint();
|
||||
}
|
||||
+17
-6
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
|
||||
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
|
||||
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
|
||||
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
|
||||
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
|
||||
Notfall::einschalten(dirname(__DIR__));
|
||||
|
||||
try {
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
} catch (Throwable $fehler) {
|
||||
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder des Luximmo-Entwurfs (luximmo-website.zip) für die Mediathek vor.
|
||||
*
|
||||
* Aus dem Ordner „assets“ des Pakets entstehen:
|
||||
* - logo.png: das Logo ohne Rand, 640 px breit (Original 2596 × 2274, Zeichen und Schriftzug),
|
||||
* - favicon.png: nur das goldene Zeichen (Kreis mit Häusern), quadratisch auf Weiß, 512 px,
|
||||
* - mietverwaltung.webp, weg.webp, sonder.webp: die drei Leistungsfotos als WebP,
|
||||
* - hero.webm: das Hero-Video unverändert,
|
||||
* - hero-poster.webp: das Standbild aus dem Video (vorher mit build-luximmo-poster.mjs als PNG).
|
||||
*
|
||||
* Aufruf: php scripts/build-luximmo-assets.php <assets-Ordner des Pakets> <Zielordner> [poster.png]
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
$poster = $argv[3] ?? '';
|
||||
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/luximmo-logo.png')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-luximmo-assets.php <Ordner mit luximmo-logo.png usw.> <Zielordner> [poster.png]\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** Verkleinert ein Bild auf eine Zielbreite und speichert es als WebP. */
|
||||
$webp = static function (GdImage $bild, string $name, int $breite) use ($ziel): void {
|
||||
$breite = min($breite, imagesx($bild));
|
||||
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
|
||||
imagewebp($neu, $ziel.'/'.$name, 80);
|
||||
echo "$name: {$breite} × {$hoehe}\n";
|
||||
};
|
||||
|
||||
foreach (['mietverwaltung', 'weg', 'sonder'] as $foto) {
|
||||
$bild = imagecreatefromjpeg($quelle.'/'.$foto.'.jpg') ?: exit("Foto fehlt: $foto.jpg\n");
|
||||
$webp($bild, $foto.'.webp', 1600);
|
||||
}
|
||||
if ('' !== $poster) {
|
||||
$webp(imagecreatefrompng($poster) ?: exit("Standbild lässt sich nicht lesen: $poster\n"), 'hero-poster.webp', 1920);
|
||||
}
|
||||
copy($quelle.'/hero.webm', $ziel.'/hero.webm');
|
||||
echo "hero.webm übernommen\n";
|
||||
|
||||
// Das Logo: Grenzen aller nicht weißen, nicht durchsichtigen Punkte, dazu die des Goldtons allein.
|
||||
$logo = imagecreatefrompng($quelle.'/luximmo-logo.png');
|
||||
imagepalettetotruecolor($logo);
|
||||
$alles = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
|
||||
$gold = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
|
||||
for ($y = 0; $y < imagesy($logo); $y += 2) {
|
||||
for ($x = 0; $x < imagesx($logo); $x += 2) {
|
||||
$c = imagecolorat($logo, $x, $y);
|
||||
[$a, $r, $g, $b] = [$c >> 24 & 0x7F, $c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
|
||||
if ($a > 100 || ($r > 235 && $g > 235 && $b > 235)) {
|
||||
continue;
|
||||
}
|
||||
$alles = [min($alles[0], $x), min($alles[1], $y), max($alles[2], $x), max($alles[3], $y)];
|
||||
if ($r - $b > 40) {
|
||||
$gold = [min($gold[0], $x), min($gold[1], $y), max($gold[2], $x), max($gold[3], $y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Ausschnitt des Logos auf eine Fläche gesetzt: durchsichtig oder weiß. */
|
||||
$ausschnitt = static function (array $kasten, int $breite, int $hoehe, int $rand, bool $weiss) use ($logo): GdImage {
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagealphablending($neu, false);
|
||||
imagesavealpha($neu, true);
|
||||
imagefill($neu, 0, 0, $weiss ? imagecolorallocate($neu, 255, 255, 255) : imagecolorallocatealpha($neu, 255, 255, 255, 127));
|
||||
imagealphablending($neu, true);
|
||||
[$x1, $y1, $x2, $y2] = $kasten;
|
||||
$b = $x2 - $x1 + 1;
|
||||
$h = $y2 - $y1 + 1;
|
||||
$faktor = min(($breite - 2 * $rand) / $b, ($hoehe - 2 * $rand) / $h);
|
||||
$zb = (int) round($b * $faktor);
|
||||
$zh = (int) round($h * $faktor);
|
||||
imagecopyresampled($neu, $logo, (int) (($breite - $zb) / 2), (int) (($hoehe - $zh) / 2), $x1, $y1, $zb, $zh, $b, $h);
|
||||
|
||||
return $neu;
|
||||
};
|
||||
|
||||
$breite = 640;
|
||||
$hoehe = (int) round(($alles[3] - $alles[1] + 1) * $breite / ($alles[2] - $alles[0] + 1));
|
||||
imagepng($ausschnitt($alles, $breite, $hoehe, 0, false), $ziel.'/logo.png', 9);
|
||||
echo "logo.png: {$breite} × {$hoehe}\n";
|
||||
|
||||
imagepng($ausschnitt($gold, 512, 512, 36, true), $ziel.'/favicon.png', 9);
|
||||
echo "favicon.png: 512 × 512 (Zeichen ohne Schriftzug)\n";
|
||||
|
||||
// Piktogramme für die Objektart der Immobilienbewertung: goldene Linien auf Creme, wie das Logo.
|
||||
// Vierfach gezeichnet und verkleinert, damit die dicken Linien glatte Kanten bekommen.
|
||||
$piktogramm = static function (string $name, callable $zeichnen) use ($ziel): void {
|
||||
$s = 4;
|
||||
$gross = imagecreatetruecolor(480 * $s, 360 * $s);
|
||||
imagefill($gross, 0, 0, imagecolorallocate($gross, 0xF7, 0xF4, 0xEF));
|
||||
$gold = imagecolorallocate($gross, 0xBD, 0xA0, 0x76);
|
||||
imagesetthickness($gross, 5 * $s);
|
||||
$p = static fn (array $punkte): array => array_map(static fn (int $v): int => $v * $s, $punkte);
|
||||
$linie = static fn (int $x1, int $y1, int $x2, int $y2) => imageline($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
|
||||
$rahmen = static fn (int $x1, int $y1, int $x2, int $y2) => imagepolygon($gross, $p([$x1, $y1, $x2, $y1, $x2, $y2, $x1, $y2]), $gold);
|
||||
$flaeche = static fn (int $x1, int $y1, int $x2, int $y2) => imagefilledrectangle($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
|
||||
$zeichnen($linie, $rahmen, $flaeche, static fn (array $punkte) => imagepolygon($gross, $p($punkte), $gold));
|
||||
$klein = imagecreatetruecolor(480, 360);
|
||||
imagecopyresampled($klein, $gross, 0, 0, 0, 0, 480, 360, 480 * $s, 360 * $s);
|
||||
imagewebp($klein, $ziel.'/'.$name, 90);
|
||||
echo "$name: 480 × 360\n";
|
||||
};
|
||||
|
||||
$piktogramm('option-wohnung.webp', static function ($linie, $rahmen, $flaeche): void {
|
||||
$linie(110, 300, 370, 300);
|
||||
$rahmen(180, 80, 300, 300);
|
||||
foreach ([110, 150, 190, 230] as $y) {
|
||||
foreach ([200, 228, 256] as $x) {
|
||||
150 === $y && 228 === $x ? $flaeche($x, $y, $x + 22, $y + 22) : $rahmen($x, $y, $x + 22, $y + 22);
|
||||
}
|
||||
}
|
||||
$rahmen(225, 262, 255, 300);
|
||||
});
|
||||
$piktogramm('option-haus.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
|
||||
$linie(110, 300, 370, 300);
|
||||
$vieleck([150, 175, 240, 98, 330, 175]);
|
||||
$rahmen(170, 175, 310, 300);
|
||||
$rahmen(226, 240, 256, 300);
|
||||
$rahmen(186, 198, 214, 226);
|
||||
$rahmen(268, 198, 296, 226);
|
||||
});
|
||||
$piktogramm('option-mehrfamilienhaus.webp', static function ($linie, $rahmen): void {
|
||||
$linie(100, 300, 380, 300);
|
||||
$rahmen(140, 100, 340, 300);
|
||||
$linie(140, 122, 340, 122);
|
||||
foreach ([140, 178, 216] as $y) {
|
||||
foreach ([162, 202, 256, 296] as $x) {
|
||||
$rahmen($x, $y, $x + 22, $y + 22);
|
||||
}
|
||||
}
|
||||
$rahmen(224, 256, 256, 300);
|
||||
});
|
||||
$piktogramm('option-grundstueck.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
|
||||
$vieleck([100, 290, 380, 290, 325, 195, 155, 195]);
|
||||
$linie(240, 245, 240, 120);
|
||||
$vieleck([240, 120, 290, 138, 240, 156]);
|
||||
foreach ([[100, 290], [380, 290], [325, 195], [155, 195]] as [$x, $y]) {
|
||||
$flaeche($x - 7, $y - 7, $x + 7, $y + 7);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Holt ein Standbild aus dem Hero-Video von Luximmo: Es steht als Hintergrundbild der Sektion,
|
||||
// bis das Video läuft (und für immer bei „Bewegung reduzieren“ oder Datensparen). ffmpeg gibt es
|
||||
// hier nicht, Chromium spielt WebM ab und zeichnet das Bild in ein Canvas.
|
||||
//
|
||||
// Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const [quelle, ziel, sekunde = '0.4'] = process.argv.slice(2);
|
||||
if (!quelle || !ziel) {
|
||||
console.error('Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent('<video muted playsinline></video>');
|
||||
const daten = readFileSync(quelle).toString('base64');
|
||||
const png = await page.evaluate(async ([b64, t]) => {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const video = document.querySelector('video');
|
||||
video.src = URL.createObjectURL(new Blob([bytes], { type: 'video/webm' }));
|
||||
await new Promise((ok, fehler) => { video.onloadeddata = ok; video.onerror = () => fehler(new Error('Video lässt sich nicht lesen')); });
|
||||
video.currentTime = Math.min(Number(t), Math.max(0, video.duration - 0.1));
|
||||
await new Promise((ok) => { video.onseeked = ok; });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
return { url: canvas.toDataURL('image/png'), breite: video.videoWidth, hoehe: video.videoHeight, dauer: video.duration };
|
||||
}, [daten, sekunde]);
|
||||
writeFileSync(ziel, Buffer.from(png.url.split(',')[1], 'base64'));
|
||||
console.log(`Standbild ${png.breite} × ${png.hoehe} (Video ${png.dauer.toFixed(1)} s) → ${ziel}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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,34 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Luximmo-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument (optional): der Ordner „assets“ aus luximmo-website.zip (Logo, Fotos, hero.webm).
|
||||
# Dann entstehen Logo, Seitensymbol, WebP-Fotos und das Standbild des Videos neu.
|
||||
# Empfaenger der Formulare per LUXIMMO_MAIL, sonst eine Platzhalteradresse.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/luximmo.db"
|
||||
export MEDIA_DIRECTORY="$root/var/luximmo-media"
|
||||
assets="$root/var/luximmo-assets"
|
||||
if [ -n "$1" ]; then
|
||||
mkdir -p "$assets"
|
||||
node scripts/build-luximmo-poster.mjs "$1/hero.webm" "$assets/hero-poster.png" 0.4
|
||||
"$php" scripts/build-luximmo-assets.php "$1" "$assets" "$assets/hero-poster.png"
|
||||
rm -f "$assets/hero-poster.png"
|
||||
fi
|
||||
if [ ! -f "$assets/hero-poster.webp" ] || [ ! -f "$assets/logo-original.svg" ] || [ ! -f "$assets/hero.webm" ]; then
|
||||
echo "Bilder fehlen: erst mit dem assets-Ordner des Pakets als Argument aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/luximmo.db"
|
||||
rm -rf "$root/var/luximmo-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-luximmo.php "$assets"
|
||||
# Instrument Sans 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/luximmo-paket.zip"
|
||||
"$php" bin/console cms:paket:export --ziel="$root/var/luximmo-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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,141 @@
|
||||
<?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');
|
||||
|
||||
// PHP-Fassungen mit bekannter Lücke fallen auf; gepatchte, unbekannte und Vorabfassungen nicht.
|
||||
$pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Bestand::phpLuecke('8.1.30'), 'Ungepatchtes und veraltetes PHP gilt als unsicher');
|
||||
$pruefe(null === Zentrale\Bestand::phpLuecke('8.4.27') && null === Zentrale\Bestand::phpLuecke('8.6.0RC4') && null === Zentrale\Bestand::phpLuecke(''), 'Gepatchtes, neueres und unbekanntes PHP gilt nicht als unsicher');
|
||||
$pruefe(array_key_exists('php_luecke', $bestand->alle(null)[0] ?? []), 'Die Übersicht kennt den PHP-Zustand');
|
||||
|
||||
// KantTresor (ADR 124): eigener Pfad, gleiches Verfahren, nie ein Auftrag. Die Gegenseite ist
|
||||
// Python (controlplane/zentrale.py in KantArchiv); hier wird sie mit denselben Feldern nachgestellt.
|
||||
$tresorId = bin2hex(random_bytes(16));
|
||||
$tresorSchluessel = bin2hex(random_bytes(32));
|
||||
$tresorMelden = static function (array $daten, ?string $schluessel = null) use ($api, $tresorSchluessel): array {
|
||||
$body = (string) json_encode($daten, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $api->meldenTresor($body, hash_hmac('sha256', $body, $schluessel ?? $tresorSchluessel), '198.51.100.4');
|
||||
};
|
||||
$tresorDaten = ['id' => $tresorId, 'adresse' => 'https://archiv.kunde-drei.de', 'fassung' => '1.82.0', 'laufzeit' => 'Python 3.12.7 · Django 5.2.8', 'datenbank' => 'postgresql', 'mandanten' => 3, 'stoerungen' => ['Objektspeicher'], 'speicher' => 93];
|
||||
$antwort = $tresorMelden([...$tresorDaten, 'schluessel' => $tresorSchluessel]);
|
||||
$pruefe(200 === $antwort['status'] && hash_equals(hash_hmac('sha256', $antwort['body'], $tresorSchluessel), (string) $antwort['signatur']), 'KantTresor-Meldung wird angenommen und unterschrieben beantwortet');
|
||||
$pruefe(!str_contains($antwort['body'], 'auftrag'), 'KantTresor bekommt nie einen Auftrag');
|
||||
$tresorZeile = $bestand->eine($tresorId, '1.82.0');
|
||||
$pruefe('kanttresor' === ($tresorZeile['produkt'] ?? null) && 3 === (int) $tresorZeile['mandanten'] && 'Objektspeicher' === $tresorZeile['stoerungen'], 'Mandanten und Störungen stehen in der Zentrale');
|
||||
$pruefe(true === $tresorZeile['gestoert'] && true === $tresorZeile['speicher_knapp'], 'Gestörter Dienst und knapper Speicher fallen auf');
|
||||
$pruefe(false === $tresorZeile['kann_update'] && null !== $bestand->beauftragen($tresorId, '1.83.0'), 'Für KantTresor lässt sich kein Update beauftragen');
|
||||
$pruefe(1 === count($bestand->alle(null, 'kanttresor')) && [] === array_filter($bestand->alle(null), static fn (array $i): bool => $i['id'] === $tresorId), 'KantSites- und KantTresor-Liste bleiben getrennt');
|
||||
$pruefe(200 === $tresorMelden([...$tresorDaten, 'stoerungen' => [], 'speicher' => 41])['status'] && false === $bestand->eine($tresorId, null)['gestoert'], 'Folgemeldung ohne Schlüssel, Störung behoben');
|
||||
$pruefe(403 === $tresorMelden($tresorDaten, str_repeat('0', 64))['status'], 'KantTresor-Meldung mit falscher Unterschrift wird abgewiesen');
|
||||
$pruefe(409 === $tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16))])['status'], 'Unbekannte KantTresor-Kennung ohne Schlüssel bekommt 409');
|
||||
$kantsitesId = (string) $pdo->query("SELECT id FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$kantsitesSchluessel = (string) $pdo->query("SELECT schluessel FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$pruefe(403 === $tresorMelden([...$tresorDaten, 'id' => $kantsitesId], $kantsitesSchluessel)['status'], 'Eine KantSites-Kennung kann nicht als KantTresor melden');
|
||||
$tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16)), 'schluessel' => $tresorSchluessel, 'fassung' => '1.83.1']);
|
||||
$pruefe('1.83.1' === $bestand->hoechsteFassung('kanttresor') && true === $bestand->eine($tresorId, '1.83.1')['veraltet'], 'Höchste gemeldete KantTresor-Fassung macht ältere zu veraltet');
|
||||
|
||||
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'];
|
||||
@@ -43,8 +43,10 @@ final class DocumentValidator
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
|
||||
public const V27_TYPES = ['language'];
|
||||
/** Schema v28: ein eigenes Video aus der Mediathek. */
|
||||
public const V28_TYPES = ['video'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 27;
|
||||
public const SCHEMA_VERSION = 28;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -155,7 +157,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 28 && in_array($type, self::V28_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -468,6 +470,22 @@ final class DocumentValidator
|
||||
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
||||
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
||||
}
|
||||
if ('video' === $type) {
|
||||
// Nur eigene Dateien: die Richtlinie der Seite erlaubt Medien von der eigenen Adresse,
|
||||
// und ein fremdes Video wäre ohnehin ein Fall für „Externer Inhalt“ mit Einwilligung.
|
||||
$this->videoPath($props['src'] ?? '');
|
||||
$this->videoPath($props['srcWebm'] ?? '');
|
||||
if ('' !== ($props['poster'] ?? '')) {
|
||||
$this->url($props['poster']);
|
||||
}
|
||||
$this->text($props['title'] ?? '', 160, true);
|
||||
$this->require(in_array($props['mode'] ?? 'player', ['player', 'loop'], true), 'Ein Video ist ein Spieler oder eine Schleife.');
|
||||
}
|
||||
if ('section' === $type && (isset($props['backgroundVideo']) || isset($props['backgroundVideoWebm']))) {
|
||||
$this->require($schemaVersion >= 28, 'Ein Hintergrundvideo braucht Schema v28.');
|
||||
$this->videoPath($props['backgroundVideo'] ?? '');
|
||||
$this->videoPath($props['backgroundVideoWebm'] ?? '');
|
||||
}
|
||||
if ('html' === $type) {
|
||||
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
|
||||
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
|
||||
@@ -567,6 +585,11 @@ final class DocumentValidator
|
||||
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
|
||||
}
|
||||
|
||||
private function videoPath(mixed $path): void
|
||||
{
|
||||
$this->require('' === $path || (is_string($path) && 1 === preg_match(VideoLibrary::PATH, $path)), 'Ein Video kommt aus der Mediathek.');
|
||||
}
|
||||
|
||||
private function url(mixed $url): void
|
||||
{
|
||||
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
*/
|
||||
final class SiteSettings
|
||||
{
|
||||
/** Die Unterarten von schema.org/LocalBusiness, die sich in den Einstellungen wählen lassen. */
|
||||
public const BUSINESS_TYPES = [
|
||||
'LocalBusiness' => 'Allgemein (LocalBusiness)',
|
||||
'RealEstateAgent' => 'Immobilien (RealEstateAgent)',
|
||||
'HomeAndConstructionBusiness' => 'Handwerk und Bau allgemein',
|
||||
'Electrician' => 'Elektriker',
|
||||
'Plumber' => 'Sanitär',
|
||||
'HVACBusiness' => 'Heizung und Klima',
|
||||
'HousePainter' => 'Maler',
|
||||
'RoofingContractor' => 'Dachdecker',
|
||||
'GeneralContractor' => 'Bauunternehmen',
|
||||
'AccountingService' => 'Steuern und Buchhaltung',
|
||||
'FinancialService' => 'Finanzdienstleistung',
|
||||
'LegalService' => 'Rechtsberatung',
|
||||
'MedicalBusiness' => 'Medizin und Pflege',
|
||||
'TravelAgency' => 'Reisebüro',
|
||||
'Restaurant' => 'Restaurant',
|
||||
'Store' => 'Geschäft',
|
||||
];
|
||||
|
||||
public const DEFAULTS = [
|
||||
'legalName' => '',
|
||||
'description' => '',
|
||||
@@ -32,6 +52,12 @@ final class SiteSettings
|
||||
'openingHours' => [],
|
||||
'areaServed' => [],
|
||||
'localBusiness' => false,
|
||||
// Genauer als „lokales Unternehmen“, dazu der Standort und ein Logo mit eigener Adresse:
|
||||
// alles nur in den strukturierten Daten, leer bleibt es dort weg (ADR 126).
|
||||
'businessType' => 'LocalBusiness',
|
||||
'latitude' => '',
|
||||
'longitude' => '',
|
||||
'organisationLogoMediaId' => '',
|
||||
'llmsTxt' => false,
|
||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||
@@ -240,6 +266,19 @@ final class SiteSettings
|
||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||
}
|
||||
$values['businessType'] = (string) ($input['businessType'] ?? 'LocalBusiness');
|
||||
if (!isset(self::BUSINESS_TYPES[$values['businessType']])) {
|
||||
throw new InvalidPage('Diese Art von Unternehmen steht nicht zur Auswahl.');
|
||||
}
|
||||
$values['latitude'] = $this->coordinate($input['latitude'] ?? '', 90, 'Breitengrad');
|
||||
$values['longitude'] = $this->coordinate($input['longitude'] ?? '', 180, 'Längengrad');
|
||||
if (('' === $values['latitude']) !== ('' === $values['longitude'])) {
|
||||
throw new InvalidPage('Für den Standort braucht es Breiten- und Längengrad, oder keinen von beiden.');
|
||||
}
|
||||
$values['organisationLogoMediaId'] = trim((string) ($input['organisationLogoMediaId'] ?? ''));
|
||||
if ('' !== $values['organisationLogoMediaId'] && (1 !== preg_match('/^[a-f0-9]{32}$/D', $values['organisationLogoMediaId']) || null === $this->media->find($values['organisationLogoMediaId']))) {
|
||||
throw new InvalidPage('Das gewählte Logo gibt es nicht (mehr).');
|
||||
}
|
||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||
@@ -512,6 +551,20 @@ final class SiteSettings
|
||||
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
||||
}
|
||||
|
||||
/** Eine Koordinate als Dezimalzahl; das Komma der deutschen Schreibweise wird zum Punkt. */
|
||||
private function coordinate(mixed $input, int $limit, string $label): string
|
||||
{
|
||||
$value = str_replace(',', '.', trim((string) $input));
|
||||
if ('' === $value) {
|
||||
return '';
|
||||
}
|
||||
if (1 !== preg_match('/^-?\d{1,3}(?:\.\d{1,7})?$/D', $value) || abs((float) $value) > $limit) {
|
||||
throw new InvalidPage(sprintf('Der %s ist keine gültige Koordinate, zum Beispiel 48.79381.', $label));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured data for the organisation, built from the maintained values only. Returns null
|
||||
* while nothing is maintained: an empty Organization block would be noise, not data.
|
||||
@@ -522,9 +575,10 @@ final class SiteSettings
|
||||
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
||||
return null;
|
||||
}
|
||||
$lokal = $settings['localBusiness'] && $hasAddress;
|
||||
$data = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
|
||||
'@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
|
||||
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
||||
'url' => $origin.'/',
|
||||
];
|
||||
@@ -542,6 +596,14 @@ final class SiteSettings
|
||||
'addressCountry' => $settings['country'],
|
||||
], static fn (string $value): bool => '' !== $value);
|
||||
}
|
||||
if ($lokal && '' !== ($settings['latitude'] ?? '') && '' !== ($settings['longitude'] ?? '')) {
|
||||
$data['geo'] = ['@type' => 'GeoCoordinates', 'latitude' => (float) $settings['latitude'], 'longitude' => (float) $settings['longitude']];
|
||||
}
|
||||
// Eine gelöschte Datei darf keine tote Adresse in die Daten bringen.
|
||||
$logo = '' !== ($settings['organisationLogoMediaId'] ?? '') ? $this->media->find($settings['organisationLogoMediaId']) : null;
|
||||
if (null !== $logo && [] !== $logo['variants']) {
|
||||
$data['logo'] = $origin.end($logo['variants'])['src'];
|
||||
}
|
||||
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
|
||||
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
||||
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
||||
|
||||
@@ -47,6 +47,7 @@ final class Translations
|
||||
'icon' => ['label'],
|
||||
'progress' => ['label'],
|
||||
'embed' => ['title', 'hint'],
|
||||
'video' => ['title'],
|
||||
];
|
||||
|
||||
/** Felder der Seite selbst und ihre Höchstlänge. */
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Videos neben den Bildern und PDFs der Mediathek.
|
||||
*
|
||||
* Wie ein PDF wird ein Video nicht umgerechnet, sondern so ausgeliefert, wie es kam: Auf dem
|
||||
* Hosting gibt es kein ffmpeg, und PHP könnte das in der Laufzeit einer Anfrage auch nicht. Deshalb
|
||||
* die Prüfung am Dateianfang statt an der Endung und beim Ausliefern Kopfzeilen, die den Browser
|
||||
* nichts anderes darin sehen lassen als ein Video.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.mp4 oder <id>.webm - so gehen sie mit jeder
|
||||
* Sicherung und jedem Website-Paket mit.
|
||||
*/
|
||||
final class VideoLibrary
|
||||
{
|
||||
/** Ein Hintergrund oder ein kurzer Film. Für Längeres ist ein Videodienst der bessere Weg. */
|
||||
public const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Videos, so steht sie im Dokument. */
|
||||
public const PATH = '#^/media/[a-f0-9]{32}\.(?:mp4|webm)$#D';
|
||||
|
||||
private const EXTENSIONS = ['video/mp4' => 'mp4', 'video/webm' => 'webm'];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM video'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM video WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
/** Soll diese Datei als Video behandelt werden? Der Inhalt entscheidet, die Endung ist Rückfall. */
|
||||
public function claims(UploadedFile $file): bool
|
||||
{
|
||||
return null !== $this->mimeOf($file->getPathname())
|
||||
|| str_starts_with((string) $file->getMimeType(), 'video/')
|
||||
|| in_array(strtolower($file->getClientOriginalExtension()), ['mp4', 'm4v', 'webm', 'mov'], true);
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 64 MB pro Video; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
$mime = $this->mimeOf($file->getPathname());
|
||||
if (null === $mime) {
|
||||
throw new \InvalidArgumentException('Das ist kein abspielbares Video. Bitte eine MP4- oder WebM-Datei wählen; MOV spielen nicht alle Browser ab.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id, self::EXTENSIONS[$mime]);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Video konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Video', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'mime' => $mime, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('video', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE video SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Videos, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id, string $extension): ?string
|
||||
{
|
||||
$item = $this->find($id);
|
||||
if (null === $item || $extension !== self::EXTENSIONS[$item['mime']]) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id, $extension);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Typ nach den ersten Bytes. MP4 trägt ab Byte 4 das Kästchen „ftyp“, WebM beginnt mit dem
|
||||
* EBML-Kopf und nennt seinen Dokumenttyp gleich danach. MOV hat auch „ftyp“, aber die Marke
|
||||
* „qt“ - das spielen nicht alle Browser ab, also wird es abgewiesen statt still zu scheitern.
|
||||
*/
|
||||
private function mimeOf(string $path): ?string
|
||||
{
|
||||
$head = (string) file_get_contents($path, false, null, 0, 4096);
|
||||
if ('ftyp' === substr($head, 4, 4) && 'qt ' !== substr($head, 8, 4)) {
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
return str_starts_with($head, "\x1A\x45\xDF\xA3") && str_contains($head, 'webm') ? 'video/webm' : null;
|
||||
}
|
||||
|
||||
private function path(string $id, string $extension): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.'.$extension;
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'mime' => $row['mime'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.'.self::EXTENSIONS[$row['mime']],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -17,7 +18,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived), 'videos' => $this->videos->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -57,7 +58,11 @@ final class MediaController extends AbstractController
|
||||
try {
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
match (true) {
|
||||
$this->isPdf($file) => $this->documents->upload($file),
|
||||
$this->videos->claims($file) => $this->videos->upload($file),
|
||||
default => $this->media->upload($file),
|
||||
};
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
@@ -120,6 +125,31 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/video/{id}', name: 'admin_video_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateVideo(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->videos->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->videos->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Video wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Video wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
@@ -195,6 +225,36 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Videos fuer die Auswahl im Editor. */
|
||||
#[Route('/%kant.admin_path%/api/videos', methods: ['GET'])]
|
||||
public function listVideos(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->videos->list(false), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/videos/upload', methods: ['POST'])]
|
||||
public function uploadVideo(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->videos->upload($file)], 201);
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Video konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -324,6 +384,20 @@ final class MediaController extends AbstractController
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Video, so wie es hochgeladen wurde.
|
||||
*
|
||||
* BinaryFileResponse beantwortet Range-Anfragen; ohne sie lässt Safari ein Video gar nicht
|
||||
* abspielen. Die Datei ändert sich nie, ihre Adresse ist an die Kennung gebunden.
|
||||
*/
|
||||
#[Route('/media/{id}.{extension}', name: 'video_serve', requirements: ['id' => '[a-f0-9]{32}', 'extension' => 'mp4|webm'], methods: ['GET'])]
|
||||
public function serveVideo(string $id, string $extension): Response
|
||||
{
|
||||
$path = $this->videos->file($id, $extension) ?? throw $this->createNotFoundException();
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'video/'.$extension, 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,7 @@ final class PageController extends AbstractController
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$needsVideo = (bool) array_filter($documents, fn (array $doc): bool => $this->hasLoopVideo($doc['blocks'] ?? []));
|
||||
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
|
||||
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
|
||||
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
|
||||
@@ -326,7 +327,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 +379,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(),
|
||||
@@ -391,6 +405,7 @@ final class PageController extends AbstractController
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsVideo' => $needsVideo,
|
||||
'needsMotion' => $needsMotion,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
@@ -429,8 +444,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 || $needsVideo || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $embeds['hosts'];
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
@@ -453,9 +468,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 +541,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 +783,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 {
|
||||
@@ -803,6 +862,21 @@ final class PageController extends AbstractController
|
||||
return $walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
/** Schleifenvideos brauchen video.js: es startet sie und gibt ihnen den Knopf zum Anhalten. */
|
||||
private function hasLoopVideo(array $blocks): bool
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$props = $block['props'] ?? [];
|
||||
$loop = 'video' === ($block['type'] ?? null) && 'loop' === ($props['mode'] ?? null) && '' !== ($props['src'] ?? '');
|
||||
$background = 'section' === ($block['type'] ?? null) && ('' !== ($props['backgroundVideo'] ?? '') || '' !== ($props['backgroundVideoWebm'] ?? ''));
|
||||
if ($loop || $background || $this->hasLoopVideo($block['children'] ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasType(array $document, string $type): bool
|
||||
{
|
||||
$found = false;
|
||||
@@ -1079,23 +1153,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 +1182,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' => []],
|
||||
]],
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $settings,
|
||||
'businessTypes' => SiteSettings::BUSINESS_TYPES,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
|
||||
@@ -7,10 +7,14 @@ 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\Routing\Attribute\Route;
|
||||
@@ -24,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)
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -43,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,
|
||||
@@ -59,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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -189,6 +199,95 @@ final class SystemController extends AbstractController
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt
|
||||
// beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite
|
||||
// zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */
|
||||
#[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])]
|
||||
public function updateCheck(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$installiert = $this->updater->version();
|
||||
$neueste = $this->archive->latest();
|
||||
$antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()];
|
||||
if (null === $neueste) {
|
||||
$antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.';
|
||||
} elseif (version_compare($neueste['version'], $installiert, '>')) {
|
||||
$antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)];
|
||||
}
|
||||
|
||||
return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
/** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */
|
||||
#[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])]
|
||||
public function updateStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch
|
||||
* als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite
|
||||
* danach selbst auf.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])]
|
||||
public function updateStart(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) {
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->run($request->request->getString('version') ?: null);
|
||||
if ($ergebnis['migrate'] ?? false) {
|
||||
$this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]);
|
||||
}
|
||||
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
|
||||
|
||||
return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der
|
||||
* Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist
|
||||
* wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])]
|
||||
public function updateFinish(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$offen = $this->archive->pending();
|
||||
if (null === $offen) {
|
||||
return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]);
|
||||
}
|
||||
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) {
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
|
||||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/** Wie sendAndStop, nur als JSON für die Systemseite. */
|
||||
private function sendJsonAndStop(array $daten): never
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Robots-Tag: noindex');
|
||||
echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112).
|
||||
*
|
||||
@@ -379,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
|
||||
@@ -386,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);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\LogoutException;
|
||||
|
||||
/**
|
||||
* Jeder Serverfehler, den das Framework abfängt, steht auch in var/log/notfall.log (ADR 113).
|
||||
*
|
||||
* Ohne das gab es auf dem Hosting kein eigenes Protokoll: Symfony schreibt nach stderr, und das
|
||||
* landet - wenn überhaupt - im Fehlerlog des Hostings, das ein Betreiber erst suchen muss. 404 und
|
||||
* andere gewollte Antworten gehören nicht hinein. Die Antwort selbst wird zur Wartungsseite.
|
||||
*/
|
||||
final class ErrorLogSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private bool $debug = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach der Sicherheitsschicht (Priorität 1): „Zugriff verweigert“ ist dort eine
|
||||
// Weiterleitung zur Anmeldung, kein Notfall. Vor der Fehlerseite des Frameworks (-128),
|
||||
// damit der Eintrag auch dann entsteht, wenn sie selbst scheitert.
|
||||
return [KernelEvents::EXCEPTION => ['onException', -64]];
|
||||
}
|
||||
|
||||
public function onException(ExceptionEvent $event): void
|
||||
{
|
||||
$fehler = $event->getThrowable();
|
||||
if ($event->hasResponse() || $fehler instanceof AuthenticationException || $fehler instanceof AccessDeniedException || $fehler instanceof LogoutException
|
||||
|| ($fehler instanceof HttpExceptionInterface && $fehler->getStatusCode() < 500)) {
|
||||
return;
|
||||
}
|
||||
Notfall::protokollieren($fehler, $event->getRequest()->getMethod().' '.$event->getRequest()->getRequestUri());
|
||||
// Besucher sehen statt einer englischen „Oops“-Seite die Wartungsseite mit 503: für
|
||||
// Suchmaschinen „gleich wieder da“. Im Entwicklungsmodus bleibt die ausführliche Seite.
|
||||
if (!$this->debug) {
|
||||
$event->setResponse(new Response(Notfall::html(), Response::HTTP_SERVICE_UNAVAILABLE, Notfall::KOPFZEILEN));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user