Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07154eec1 | ||
|
|
7eb462ac7b | ||
|
|
45040b2ca6 | ||
|
|
937344e642 | ||
|
|
232e6fe6dc | ||
|
|
533e181590 | ||
|
|
5c2dabdd5d | ||
|
|
32ee28888e | ||
|
|
f6350de636 | ||
|
|
d05dba3042 | ||
|
|
4d1f51801c |
No files matched your search
+12
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-539a85bd3015/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8087 -t public bin/dev-router.php"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8087
|
||||
"port": 8091
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -58,6 +58,16 @@
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
},
|
||||
{
|
||||
"name": "maler-drews",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8093
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,8 @@ APP_DEBUG=0
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
|
||||
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
|
||||
# Die oeffentliche Adresse der Website, mit oder ohne www - so, wie sie in Sitemap, Feed und
|
||||
# Canonical stehen soll. Die andere Schreibweise leitet die Anwendung dorthin um.
|
||||
SITE_ORIGIN=https://example.de
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
# KantSites
|
||||
|
||||
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
|
||||
Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Vier Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
|
||||
|
||||
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
|
||||
|
||||
**Rollen.** Redakteure bearbeiten Entwürfe, laden Medien hoch und sehen die Statistik. Veröffentlicher dürfen zusätzlich veröffentlichen, Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite pflegen. Administratoren allein ändern Design, Unternehmensdaten (samt Einwilligung und Spamschutz), Benutzer, System und eigene Skripte. Vorher genügte für Design, Einstellungen, Menüs, Bereiche und Formulare die Rolle Redakteur – entgegen der Rollenhilfe. Die Navigation zeigt nur, was die Rolle öffnen darf.
|
||||
|
||||
**Behoben.** Nach `cms:admin:path` war keine Anmeldung möglich (Sitzungscookie blieb auf `/admin`). Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete. Ein MySQL-Passwort mit Sonderzeichen ließ den Container abbrechen (`resolve:`), ebenso ein SMTP-Passwort. Die Einwilligung nahm Entscheidungen von fremden Websites an und leitete auf `/\fremd.example` weiter. `</script>` in Titel oder FAQ zerriss die strukturierten Daten. Eigenes CSS konnte mit einem Komma den Block verlassen. Eine alte Fassung konnte ein Skript an der Administratorregel vorbei zurückholen. Im Editor ließen sich Menü, Beitragsliste und Slider in älteren Dokumenten nicht speichern (Schemafassung), „Gestaltung einfügen“ und „Als Vorlage speichern“ waren fälschlich aktiv, „Ausrücken“ und Baum-Drop umgingen die Elternregeln, ein Bild auf ein Akkordeon warf einen Fehler, abgelehnte Einfügungen hinterließen leere Verlaufsschritte. Dazu: Schatten bei Überfahren/Scrollen, Tabs und Bildbetrachter in Kopf, Fuß und Sektionen, Sitemap-`lastmod`, Feed-Zeitzone, Vollbild in eingebetteten Videos, Update-Downloads nur über HTTPS-Weiterleitungen, Composer- und PHP-Aufruf aus dem Web-Update unter php-fpm, unerwartete Fehler im Updater nehmen den Rückweg, eine geplante Veröffentlichung blockiert die anderen nicht mehr, keine Unterseite unter einer Seite im Papierkorb.
|
||||
|
||||
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
|
||||
|
||||
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
|
||||
|
||||
## Eigene Absenderadresse je Formular (ab 1.15.1)
|
||||
|
||||
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
@@ -176,7 +192,7 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
|
||||
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||
|
||||
@@ -242,6 +258,8 @@ 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. 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`.
|
||||
|
||||
`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.
|
||||
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
@@ -291,7 +309,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv
|
||||
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
|
||||
## Verwaltung ab 1.7.0
|
||||
|
||||
@@ -321,6 +339,14 @@ In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
### Dritte lokale Referenzwebsite: Tagespflege Moldovan
|
||||
|
||||
Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-moldovan.db` mit Medien in `var/tagespflege-moldovan-media`; die Startkonfiguration `tagespflege-moldovan` in `.claude/launch.json` nutzt Port 8089. Der Inhalt entsteht ausschließlich über `scripts/seed-tagespflege-moldovan.php <Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png>`; `sh scripts/reset-tagespflege-moldovan.sh [Ordner]` (Standard `var/tagespflege-moldovan-assets`) wirft die Installation weg, migriert neu und baut sie wieder auf. Vorlage war das gelieferte Paket `Tagespflege_Moldovan_Website.zip` (09.09.2026); die bewussten Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
|
||||
|
||||
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
@@ -8,7 +8,9 @@ framework:
|
||||
cookie_httponly: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
cookie_path: /admin
|
||||
# Der Pfad folgt der Adresse der Verwaltung: hiesse sie anders und das Cookie weiter
|
||||
# /admin, schickte der Browser es nie mit, und keine Anmeldung kaeme je an.
|
||||
cookie_path: '/%kant.admin_path%'
|
||||
name: KANTSESSION
|
||||
router:
|
||||
utf8: true
|
||||
@@ -19,6 +21,7 @@ framework:
|
||||
log: true
|
||||
mailer:
|
||||
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
|
||||
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
|
||||
# MAILER_DSN in .env for SMTP. Delivery failures never lose a submission: it is
|
||||
# stored first and stays visible as undelivered in the administration.
|
||||
dsn: '%env(resolve:MAILER_DSN)%'
|
||||
# Ohne `resolve:`: ein SMTP-Passwort mit Prozentzeichen ist kein Parametername.
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
+21
-7
@@ -26,14 +26,18 @@ services:
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
# Mit oder ohne www entscheidet SITE_ORIGIN; die andere Schreibweise leitet dorthin um.
|
||||
App\EventSubscriber\CanonicalHostSubscriber:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$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.
|
||||
@@ -67,20 +71,25 @@ services:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Command\AdminPathCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Ops\ArchiveUpdate:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
App\Ops\Updater:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
App\EventSubscriber\InstallSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -91,13 +100,13 @@ services:
|
||||
App\Ops\Installation:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Ops\Backup:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
@@ -118,6 +127,11 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
# `%env(projectdir:...)%`: ersetzt nur %kernel.project_dir%. Symfonys `resolve:` laese ein
|
||||
# prozentkodiertes MySQL-Passwort (`p%40ss`) als Parameter und brauche den Container.
|
||||
App\Infrastructure\ProjectDirEnvProcessor:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
+42
-2
@@ -2,6 +2,46 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 100 – Gesamtprüfung: Rollen wie versprochen, Installer bleibt zu, Einwilligung nur von hier (15.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte eine umfangreiche Prüfung – Fehler, Durchgängigkeit, Sauberkeit – und ausdrücklich die Frage, ob jemand ohne Administrator etwas verändern kann. Sechs getrennte Durchsichten (Backend, Betrieb, Editor, öffentliche Ausgabe, Dokumentation, Autorisierung jeder Route) lieferten rund sechzig Befunde; die mit Wirkung auf Sicherheit oder Bedienbarkeit sind umgesetzt, der Rest steht in STATUS als Rückstand.
|
||||
|
||||
**Rollen.** Die Rollenhilfe versprach „Design, Einstellungen: Administrator“ und „Redakteur: nur Entwürfe“, der Code verlangte für Design, Einstellungen, Bereiche, Menüs und Formulare aber nur `ROLE_EDITOR` – ein Redakteur konnte Kopf und Fuß live umschreiben, die Empfängeradresse der Formulare ändern, die Website per `robotsExtra` aus dem Index nehmen. Entscheidung: der Code folgt der Hilfe. **Redakteur** = Entwürfe von Seiten und Beiträgen, Medien, Statistik, eigenes Konto. **Veröffentlicher** = dazu veröffentlichen, Bereiche (Speichern ist dort Veröffentlichen), Menüs, Formulare, Weiterleitungen, 404-Seite, Einstellungen lesen. **Administrator** = dazu Design, Unternehmensdaten samt Einwilligung und Spamschutz, Benutzer, System, Aufgaben, eigene Skripte, endgültiges Löschen. Die Navigation zeigt je Rolle nur, was sie öffnen darf; die Unternehmensdaten sind für Veröffentlicher ein Hinweis statt eines Formulars. `tests/EditorSecurityTest.php` hält die Grenze mit einer Tabelle „Rolle, Route, 403“ fest, dazu die Navigation eines Redakteurs und den Weg ohne Anmeldung.
|
||||
|
||||
**Installer.** `Installation::isInstalled()` las jede Ausnahme als „nicht installiert“ – auch eine tote Datenbankverbindung. Bei einer Wartung des Hosters oder einem gedrehten Passwort stand der Installer offen, und jeder Besucher hätte eine eigene Datenbank samt Administratorkonto eintragen können. Jetzt zählt die Marke `var/installed` immer, eine fehlende Tabelle heißt „leer“, eine fehlende Verbindung heißt „gestört“, sobald eine Datenbank eingetragen ist. `tests/InstallationTest.php` prüft alle vier Lagen.
|
||||
|
||||
**Einwilligung.** `/einwilligung` hat keine Sitzung und deshalb kein CSRF-Token; ein fremdes Formular konnte jedem Besucher „alles akzeptiert“ ins Cookie schreiben. Die Route liest jetzt `Sec-Fetch-Site`, ersatzweise `Origin`/`Referer`, und merkt sich nur Entscheidungen von dieser Website; ohne Herkunftsangabe (alte Browser) bleibt sie beantwortbar. Der Rücksprung akzeptiert nur Pfade, die weder mit `//` noch mit `/\` beginnen – Browser lesen beide als protokollrelativ.
|
||||
|
||||
**Ausgabe.** JSON-LD wird mit `JSON_HEX_TAG|JSON_HEX_AMP` kodiert: ein `</script>` in Titel oder FAQ-Antwort hätte den Datenblock beendet und den Rest des Kopfes als Markup gerendert. Eigenes CSS erlaubt im verschachtelten Selektor kein Komma mehr (`&,body{…}` wurde zu `#id,body{…}`). Eine Fassung wiederherstellen prüft Skripte wie das Speichern. Das Archiv escaped alte Klartext-Bereiche wie die Seiten. Tabs und Bildbetrachter in Kopf, Fuß oder verknüpfter Sektion bekommen Skript und Richtlinie.
|
||||
|
||||
**Betrieb.** `%env(resolve:…)%` las prozentkodierte Zugangsdaten (`p%40ss`) als Parameter – der Container brach nach dem Installer bei jeder Anfrage ab. Ein eigener Prozessor `projectdir:` ersetzt nur `%kernel.project_dir%` (`src/Infrastructure/ProjectDirEnvProcessor.php`); `MAILER_DSN` kommt ohne Prozessor aus. Das Sitzungscookie folgt der Adresse der Verwaltung (`cookie_path: '/%kant.admin_path%'`) – nach `cms:admin:path` war vorher keine Anmeldung möglich. Downloads bleiben auch über Weiterleitungen bei HTTPS. Der Befehlsläufer gibt Composer und Git ein Zuhause (`HOME`, `COMPOSER_HOME`, `APPDATA`) und sucht die PHP-Kommandozeile, weil `PHP_BINARY` unter php-fpm der Server selbst ist. Ein unerwarteter Fehler im Updater nimmt den Rückweg statt den Wartungszustand stehen zu lassen.
|
||||
|
||||
**Editor.** `upgradeForType` hob das Schema auf die Fassung, in der ein Typ auftauchte, nicht auf die, die seine heutigen Eigenschaften brauchen – Menü (v16), Beitragsliste (v18) und Slider (v17) waren in älteren Dokumenten sofort ungültig. `controls()` gab nach jedem Speichern alle Knöpfe des Bedienfelds frei, auch die, die das Design-Bedienfeld bewusst gesperrt hatte („Gestaltung einfügen“ ohne Zwischenablage löschte die Gestaltung, „Als Vorlage speichern“ ohne Gestaltung löschte eine Vorlage gleichen Namens); jetzt merkt sich die Sperre, was sie geschlossen hat. „Ausrücken“ und ein Zug neben ein Element der obersten Ebene folgen denselben Regeln wie jeder andere Zug. Einfügen prüft das Ziel, bevor es das Schema hebt, sonst blieb bei einer Ablehnung ein leerer Verlaufsschritt. Ein Bild auf ein Akkordeon meldet sich statt zu werfen. Klartext unter v12 wird beim Öffnen nicht als HTML gelesen.
|
||||
|
||||
**Bewusst nicht in diesem Schritt:** Archivseiten auf dieselbe Aufbaukette wie Seiten (eigene Stile von Kopf/Fuß, Schriften, Sektionen), Formulare und Beitragslisten in Bereichen, doppelte Block-IDs bei mehrfach verknüpften Sektionen, die zwei Breakpoints (650/767 px), feste Farbwerte in dunklen Paletten, MySQL-Sicherung, `.htaccess` als Betreiberdatei beim Archiv-Update, Wachstum der Release-Caches, Druckstile, Canonical blätternder Listen – Umfang und Risiko rechtfertigen je einen eigenen Schritt (Rückstand in STATUS).
|
||||
|
||||
## ADR 099 – Mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
|
||||
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
|
||||
|
||||
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
|
||||
|
||||
**Folgen:** `tests/CanonicalHostTest.php` (Paar, Fremdhost, Schema, Port, Methoden, Priorität), `tests/SiteOriginCommandTest.php`, je ein Fall in `CmsTest` (durch den Kernel) und `SystemScreenTest` (Anzeige und Hinweis). Die Startkonfigurationen der lokalen Installationen setzen `SITE_ORIGIN` auf ihre `127.0.0.1:<port>`-Adresse und sind davon nicht betroffen.
|
||||
|
||||
## ADR 098 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bei Rows die Breite ändern – am besten per Drag and Drop im Editor.“ Bis dahin gab es nur gleich breite Spalten: die klassische Vorgabe `columns` (one/two/three/four) und die freie Eigenschaft `gridColumns` erzeugten beide `repeat(n, minmax(0, 1fr))`. Ein Symbol neben einem Text – die Karte aus seinem Beispiel – brauchte eine schmale und eine breite Spalte.
|
||||
|
||||
**Entscheidung:** Eine neue freie Stileigenschaft `columnWidths` am **Zeilenblock**, Typ `fractions`: bis zu zwölf Zahlen mit Leerzeichen, `2 1` oder `66.7 33.3`, je Gerät wie jede andere freie Eigenschaft (`StyleSchema::FRACTIONS`, Prüfung in `StyleCompiler::scalar()`, null ist ausgeschlossen). Die Anteile liegen an der Zeile und nicht an den Spalten, weil eine Grenze immer zwei Spalten zugleich verändert und die Reihenfolge der Kinder die Zuordnung ist. Kein neues Dokumentschema: die Eigenschaft fällt unter die freien Stile ab v10.
|
||||
|
||||
**CSS.** `StyleCompiler::columnTracks()` schreibt je Gerät `#id{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}`. Zwei Feinheiten: Fehlende Anteile (eine später eingefügte Spalte) bekommen den Durchschnitt, überzählige fallen weg, also passt die Regel immer zur Kinderzahl. Und die Desktop-Regel trägt eine ID und würde die Klasse `.d-mobile-columns-one` schlagen, mit der ein Handy die Spalten stapelt – deshalb bekommt ein Gerät, dessen wirksame Aufteilung `one` (klassisch) oder `gridColumns` 1 ist, unter seiner Medienabfrage `minmax(0,1fr)`. Die Abfragen sind die des Stilcompilers (1024/767 px), nicht die 650 px der klassischen Klassen; zwischen 651 und 767 px stapelt eine Zeile mit Anteilen also, wo sie ohne Anteile noch zwei Spalten hätte. Das ist gewollt: Querformat-Handys sind Handys.
|
||||
|
||||
**Editor.** `canvas-overlay.js` zeichnet für die gewählte Zeile – oder die Zeile um die gewählte Spalte – einen Griff auf jede Grenze zwischen zwei nebeneinander liegenden Spalten (`.ov-resize`, ein Knopf mit Beschriftung „Breite zwischen Spalte 1 und 2 ändern“). Ausgangswert ist die **gemessene** Breite der Spalten, nicht der gespeicherte Wert, damit der Griff auch bei geerbten oder klassischen Aufteilungen dort anfängt, wo die Spalten wirklich stehen. Während des Ziehens wird die Zeile im Vorschaudokument inline umgestellt (`grid-template-columns`), erst beim Loslassen entsteht **ein** Verlaufsschritt (`onResize` → `setStyle(row, device, 'columnWidths')`); die Vorschau lädt danach vom Server neu. Pfeiltasten verschieben um fünf Punkte und behalten den Fokus auf dem Griff über die Neuzeichnung hinweg. Kein Griff, wo die Spalten gestapelt sind, und keiner während ein Element gezogen wird. Im Design-Bedienfeld steht derselbe Wert als Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen), für alle, die lieber Zahlen tippen.
|
||||
|
||||
**Verworfen:** Anteile je Spalte (`span` aus zwölf) wie in Flatsome – zwei Werte je Grenze zu pflegen und eine Summe, die 12 ergeben muss, sind für ein Drag-Ergebnis unnötig streng; Anteile summieren sich beliebig. Eine eigene Klasse je Verhältnis (`1-3`, `2-3`) – deckt genau die Fälle ab, die jemand vorhergesehen hat, und das Ziehen erzeugt jeden anderen.
|
||||
|
||||
## ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
|
||||
|
||||
**Anlass.** Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
|
||||
@@ -414,7 +454,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe,
|
||||
|
||||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||||
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
|
||||
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
|
||||
|
||||
@@ -1106,7 +1146,7 @@ Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funkt
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
|
||||
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local` (seit 1.7.0: `.env`, siehe ADR zur Umgebungsdatei), nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
|
||||
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
|
||||
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
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.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
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.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
@@ -30,7 +30,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
|
||||
|
||||
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
|
||||
|
||||
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
|
||||
+114
@@ -1,5 +1,119 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.17.0 – Gesamtprüfung, Adresse mit oder ohne www, Rollen wie versprochen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Komplette Analyse – Bugs, Durchgängigkeit, ob alles sauber ist; was man besser machen kann; und: die URL soll aus env kommen, www oder ohne, die Seite soll darauf reagieren.“ Nachgeschoben: „prüfst du auch, ob man ohne Admin etwas verändern kann?“
|
||||
|
||||
**Prüfung.** Sechs getrennte Durchsichten: Backend (Controller, Content, Security, Twig), Betrieb (Ops, Commands, Subscriber, Config, Installer, CI), Editor (JS, Admin-Templates, Modelltests), öffentliche Ausgabe (Templates, CSS, Skripte, SEO, CSP, Einwilligung), Dokumentation und Repository (README, docs, Skripte, Ports, Versionen, git-Hygiene) sowie eine Routenmatrix aller 63 Verwaltungs- und 15 öffentlichen Routen mit Firewall, Rolle, CSRF und Testabdeckung. Vorab: `phpunit` 512 grün, `npm test` 33 grün, Twig/YAML/Container-Lint ohne Befund, php-cs-fixer eine vorbestehende Zeile in `CmsTest`. Repository-Hygiene in Ordnung (313 Dateien, keine Geheimnisse, `var/`, `reports/`, `.env` ignoriert, `VERSION` = Tag = STATUS).
|
||||
|
||||
**Antwort auf die Rollenfrage:** Ohne Anmeldung kann niemand Inhalte, Einstellungen oder Konten ändern – alle Verwaltungsrouten liegen hinter der Firewall, jede schreibende prüft Rolle und CSRF-Token. Zwei echte Lücken gab es trotzdem, beide behoben: (1) Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete (Wartung beim Hoster, gedrehtes Passwort) – dort hätte jeder eine eigene Datenbank samt Administratorkonto eintragen können. (2) Die Rolle Redakteur durfte Design, Einstellungen (Startseite, `robotsExtra`, Turnstile, Einwilligung), Bereiche, Menüs und Formulare samt Empfängeradresse live ändern, entgegen der Rollenhilfe. ADR 100.
|
||||
|
||||
**www / SITE_ORIGIN (ADR 099):** `CanonicalHostSubscriber` leitet die jeweils andere Schreibweise mit 301 auf die eingetragene Adresse um (Pfad und Abfrage bleiben, Schema und Port aus `SITE_ORIGIN`, auch für die Verwaltung); fremde Hosts, IP-Adressen und localhost bleiben erreichbar, nur GET/HEAD, kein HTTPS-Zwang. Neu `php bin/console cms:site:origin <adresse>`; der Bildschirm „System“ zeigt Adresse, Quelle und einen Hinweis bei abweichendem Host. Der www-Block in `public/.htaccess` ist weg, `.env.example` erklärt die Einstellung, LIVEGANG Punkt 3 und README angepasst.
|
||||
|
||||
**Behoben (Auswahl, alles mit Test):** Sitzungscookie folgt `ADMIN_PATH` (Anmeldung nach `cms:admin:path` war unmöglich); `%env(resolve:)%` gegen prozentkodierte Passwörter durch `projectdir:`-Prozessor ersetzt; Einwilligung nur von dieser Website und kein `/\`-Rücksprung; JSON-LD mit `JSON_HEX_TAG`; Komma im eigenen CSS; Skriptprüfung beim Wiederherstellen einer Fassung; keine Unterseite unter einer Seite im Papierkorb; geplante Veröffentlichung blockiert bei einem Fehler nicht die anderen; Archiv escaped alte Bereiche; Feed-Zeitzone UTC; Sitemap-`lastmod` aus der Fassung; Vollbild in Embeds; Tabs/Bildbetrachter in Bereichen und Sektionen; Schatten bei Überfahren/Scrollen erbt Form; Download nur über HTTPS-Weiterleitungen; Composer/Git-Umgebung und PHP-Kommandozeile im Befehlsläufer; Updater fängt jeden Fehler. Editor: Schemafassung für Menü/Beitragsliste/Slider, gesperrte Design-Knöpfe bleiben gesperrt, Ausrücken/Baum-Zug mit Regeln, kein leerer Verlaufsschritt bei Ablehnung, Bild auf Akkordeon meldet sich, Klartext unter v12, Zahlenfelder ab 0, Farbwähler ohne `onComplete`. Docs: `.env.local`-Reste, `motion`/`archive-update` als npm-Skripte, README-Einleitung und dritte Referenzwebsite, UPDATE.md, `install.php` erkennt `.env`, Release-Builder ohne `LICENSE`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 grün (neu: `CanonicalHostTest` 24 Fälle, `SiteOriginCommandTest`, `ProjectDirEnvProcessorTest`, Rollen-Matrix und Cookie-Pfad in `EditorSecurityTest`, Installer-Fälle in `InstallationTest`, Systembildschirm, Tabs im Fuß, Sitemap-`lastmod`); `npm test` 33 grün (Fassungstabelle angepasst); Twig/YAML/Container-Lint ohne Befund; php-cs-fixer ohne Befund; `cms:health` gegen die Entwicklungsdatenbank mit dem neuen Prozessor `OK`; `npm run test:editor` (Playwright + axe, `PHP_BINARY=C:/php84/php.exe`) vollständig bestanden – ein erster Lauf scheiterte an einer seit 1.16.1 veralteten Zusicherung des Skripts („Spalte in der Sektion weiterhin gesperrt“), die jetzt die erlaubte Spalte erwartet. Nicht geprüft: Firefox/Safari, ein echtes Touchgerät, das Hosting, ein echter MySQL-Server.
|
||||
|
||||
**Rückstand (aus der Prüfung, nicht umgesetzt):** Archivseiten auf die Aufbaukette der Seiten heben (Kopf/Fuß-Stile, Schriften, Sektionen, Nach-oben); Formulare, Brotkrumen und Beitragslisten in Bereichen/Sektionen sind dort tot; doppelte Block-IDs bei mehrfach verknüpfter Sektion; Breakpoints 650 vs. 767 px vereinheitlichen; feste Farbwerte (`.tone-ink`, Skip-Link, CTA-Button, Fehlerrot, `color-scheme`) bei dunklen Paletten; MySQL ohne Sicherung, Archiv-Update und Rollback; Archiv-Update überschreibt `.htaccess` (HTTPS-Schalter); Release-Caches wachsen; Rollback-Kopie des Git-Updaters ohne Sperre; Archiv komplett im Speicher; Canonical/`rel=prev/next` blätternder Beitragslisten; Druckstile; Editor-Vorschau ohne eigenes CSS/Bereichsstile/Formulare; Skripte in Bereichen laufen nie; vier Blocktypen direkt nach dem Einfügen ungültig (Kontakt, Formular, Sektion, Embed); Rich-Text-Normalisierung erst nach Neuladen sichtbar; `legal.label` unvalidiert; verrutschte Docblocks; CI-`mysql`-Job ohne Tests; Ports 8090/8092 in QUALITY/STATUS sind Messserver, nicht die Startkonfigurationen.
|
||||
|
||||
**Veröffentlicht als 1.17.0** (kein neues Dokumentschema, keine Migration; bestehende Redakteurskonten verlieren Design, Einstellungen, Bereiche, Menüs und Formulare – siehe Eintrag oben). **Nächster Schritt:** `SITE_ORIGIN` auf `web.kant-it.de` und dem Drews-Hosting prüfen und die HTTPS-Zeilen in `.htaccess` einschalten.
|
||||
|
||||
## 1.16.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.1` (Commit `937344e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.1 – einzelne Spalte in einer Sektion, Ausrichtung für Buttons, breite und volle Abschnitte gegen die Design-Inhaltsbreite, Nach-oben-Knopf in einer Landmarke, Cormorant Garamond lokal.
|
||||
|
||||
**Anlass:** Die neu aufgebaute Drews-Datenbank trägt das Token „Ausrichtung“ an Buttons; unter dem auf dem Hosting laufenden 1.16.0 lehnte der Validator diese Dokumente beim Rendern ab (500 auf jeder öffentlichen Seite, Verwaltung lief). Das Release bringt Code und Datenbank wieder zusammen.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.1 v1.16.1`, `php scripts/build-release.php` darin): 3552 Dateien im Archiv, 4,7 MB.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.1.zip` (SHA-256 `2922027abb648839b89962351aa3dbeaa9e354dd985c94a7a2efddabb86d6731`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.16.0 → 1.16.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.1`. Die Prüfungen des Codes stehen in den beiden Einträgen darunter.
|
||||
|
||||
**Nächster Schritt:** auf dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach `var/cache/prod` prüfen; die kopierte Datenbank kann bleiben.
|
||||
|
||||
## Einzelne Spalte in einer Sektion, Buttons ausrichten (15.09.2026)
|
||||
|
||||
**Anlass:** Umut konnte im Editor keine Spalte direkt in eine Sektion legen (ausgegraut) – so zentriert er einen Button –, und der Button „Alle Mitteilungen“ auf der Drews-Startseite stand links, ohne eine Option dafür.
|
||||
|
||||
**Editor (`editor-model.js`):** `REQUIRE.column` ist jetzt `['row', 'section']`. Eine einzelne Spalte darf direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente), in einer Spalte oder lose im Dokument weiter nicht. Test angepasst.
|
||||
|
||||
**Buttons (`design-tokens.json`, `site.css`, `design.css` neu gebaut):** Das Design-Token „Ausrichtung“ gilt jetzt auch für Buttons. Weil ein Button `inline-flex` ist, zentriert ihn `margin:auto` nicht; `site.css` macht ihn bei `align-center`/`-right` zum Block mit eigener Breite (`display:flex;width:fit-content;margin-inline:auto`, dazu `justify-self` für Raster), je Gerät an den Breakpoints von `design.css` (1024/650 px). Das Drews-Aufbauskript nutzt das Token für alle mittigen Buttons statt der wirkungslosen auto-Ränder.
|
||||
|
||||
**Geprüft:** `npm test` 33 grün (darunter der geänderte Einfügetest), PHP-Tests zu Design-Tokens 11 grün; Drews-Startseite nach Neuaufbau: „Alle Mitteilungen“ und „Alle Projekte ansehen“ mittig, Aufruf-Buttons mittig, auf 1440 und 390 px. Unveröffentlicht, gehört mit den beiden Korrekturen darunter in 1.16.1.
|
||||
|
||||
## Ihr Malerservice Drews nachgebaut, 15.09.2026
|
||||
|
||||
**Anlass:** Umut wollte maler-drews.com (Flatsome-Auftritt, Malermeister Axel Drews, Gernsbach) nach dem bekannten Prinzip in eigener Installation nachbauen – exakt im Viewer, mit Lighthouse, axe und maschinenlesbaren Ausgaben auf 100.
|
||||
|
||||
**Vorlage ausgelesen, nicht geschätzt.** Alle 9 Seiten und 31 Beiträge der laufenden Website als HTML geholt, Struktur, eigenes CSS (`malerdrews-*`, `drews-*`), Abschnittsfarben und Inline-Werte extrahiert, Screenshots in 1440 und 390 px als Vergleich. Befund: Lato + Cormorant Garamond 700, Gold `#c6a36b`, Creme `#f7f5f2`, dunkle Bänder `#232323`/`#111`/`#0a0a0a`, Kopf 90 px weiß mit Versalmenü, Startbild als Slider mit drei Folien und goldener Welle, Kartenraster aus Bild und Verlauf, Zickzackliste der Projekte, Zeitleiste der Mitteilungen. `/about-us` und `/innenaustattung` sind auf der Vorlage Lorem-ipsum-Reste, `/little-greene` ein 404.
|
||||
|
||||
**Nachbau (`scripts/seed-maler-drews.php`, `scripts/seed-maler-drews-inhalte.php`, `scripts/reset-maler-drews.sh`).** Eigene Installation `var/maler-drews.db`, `var/maler-drews-media`, Bildquellen `var/maler-drews-assets` (139 Bilder, auf 2400 px verkleinert, Zuordnung in `manifest.json`), Startkonfiguration `maler-drews` auf Port 8093. Acht Seiten (Start, Aktuelles, Unsere Projekte, Kunstmalerei, Kontakt, Impressum, Datenschutz, 404), 31 Beiträge mit Bildkopf, Text, Galerie mit Bildbetrachter, Nachbarn und Aufruf, Kategorien „Aktuelles“ (5) und „Projekte“ (26); die Listen sind Beitragslisten-Bausteine (Karten auf der Startseite, Liste als Zeitleiste, Wechselseitig als Zickzack). Kopf mit Marke und Versalmenü, Fuß in vier Spalten mit Instagram, Öffnungszeiten und Rechtszeile. Formular serverseitig, Google-Karte und elf Videos als eingebettete Inhalte hinter der Einwilligungsleiste. Impressum und Datenschutz wörtlich übernommen (die Datenschutzerklärung nennt Google Analytics und reCAPTCHA, die der Nachbau nicht nutzt – vor dem Livegang zu bereinigen). Weiterleitungen für die drei toten Vorlagenadressen und die eine über 80 Zeichen lange Beitragsadresse. Bewusste Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Gold auf Weiß erreicht 2,4:1 – Kleinzeilen, Datum, Links und aktive Menüpunkte auf hellem Grund tragen `@gold-text` (`#8a6a2f`, 5,0:1), Buttons dunkle statt weiße Schrift auf Gold (6:1), der Einwilligungsknopf ebenso; vor den Beitragslisten steht eine nur für Vorlesesoftware sichtbare H2; der Copyright-Link ist unterstrichen. Das Startbild wird über den äußeren Abschnitt vorgeladen (nur das erste Hintergrundbild einer Seite bekommt ein `preload`).
|
||||
|
||||
**Zwei Lücken im CMS behoben (`site.css`, `page.html.twig`, unveröffentlicht, gehört in 1.16.1):** Abschnitte mit Breite „breit“ oder „voll“ blieben so schmal wie alle anderen, weil `.kant .shell{max-width:var(--k-container)}` des Designs die Breitenregeln schlug – jetzt mit `.kant` davor. Der Nach-oben-Knopf stand außerhalb jeder Landmarke (axe „region“) und steckt jetzt in einem `nav`. Beim Bauen gelernt: eigenes CSS erlaubt kein `@media`, keine Anführungszeichen, keine Klammern in Selektoren (`:not()`, `nth-child()`), keine Kommaselektoren, und eine leere `&{…}`-Regel geht verloren – Eigenschaften des Elements ohne `&{}` schreiben.
|
||||
|
||||
**Geprüft:** Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus (`APP_ENV=prod`, Port 8092) auf sechs Adressen (`/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`): **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 (Startseite 99 mobil / 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, sonst 100; LCP ist jeweils das Foto hinter dem Seitenkopf, mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte in `reports/lighthouse-maler-drews/`. Drei Messrunden davor zeigten Barrierefreiheit 91–93 (Gold-Kontrast, Überschriftenfolge, Link nur durch Farbe), dann 96–100 (aktiver Menüpunkt, Formularbeschriftungen), jetzt 100. axe (WCAG 2.0/2.1 A+AA und Best Practice, Playwright, 1440 und 390 px, nach dem Durchscrollen) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); vorher 58, davon 36 Kontrast, 18 „region“ am Nach-oben-Knopf, 2 Überschriftenfolge, 2 Link im Text. Maschinenlesbar: `llms.txt` mit allen Seiten und Beiträgen, `sitemap.xml` mit 41 Adressen, `robots.txt`, `feed.xml`, `LocalBusiness` mit Adresse, Öffnungszeiten, Einsatzgebiet und Instagram als `sameAs`. Alle 41 Adressen der Vorlage antworten mit 200 oder 301. Screenshots des Nachbaus in 1440 und 390 px gegen die der Vorlage verglichen (Startseite, Aktuelles, Projekte, Kunstmalerei, Kontakt, Beitrag, Impressum). PHP-Tests 512 grün (PHP 8.4), `npm test` 33 grün, Twig-Lint, php-cs-fixer auf den neuen Skripten.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den Drews-Dokumenten im Browser (kein Anmelden mit Passwort in dieser Sitzung), Firefox/Safari, ein echtes Touchgerät, das Hosting. Die Videos liegen weiter auf maler-drews.com und brauchen für den Livegang einen eigenen Speicherort.
|
||||
|
||||
## 1.16.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.0` (Commit `32ee288`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.0 – Spaltenbreiten einer Zeile durch Ziehen im Editor.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.0 v1.16.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.0.zip` (4,7 MB, SHA-256 `8aa8d40d79dfe7bf972ca005ed92d688cf63beb93a4807c20d5dcabb9b2f9264`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.1 → 1.16.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.0`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann im Editor eine Zeile wählen und die Spaltengrenze ziehen – etwa bei einer Karte mit Symbol links und Text rechts.
|
||||
|
||||
## 1.16.0 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut, mit der Karte „HausKlar“ (Symbol links, Text rechts) als Beispiel: „bei Rows die Breite ändern, am besten per Drag and Drop im Editor.“ Zeilen kannten bisher nur gleich breite Spalten. Entscheidung und Begründung: ADR 098.
|
||||
|
||||
**Umsetzung:** Neue freie Stileigenschaft `columnWidths` an der Zeile (Typ `fractions`, `StyleSchema::FRACTIONS`, z. B. `66.7 33.3`), je Gerät, ohne neues Dokumentschema. `StyleCompiler::columnTracks()` macht daraus `grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)`, füllt fehlende Anteile mit dem Durchschnitt auf, kappt überzählige und setzt ein Gerät, dessen Aufteilung stapelt (`one` oder `gridColumns` 1), auf eine Spur – sonst schlüge die ID-Regel des Desktops die Stapel-Klasse des Handys. Im Editor (`canvas-overlay.js`) liegt auf jeder Grenze zwischen zwei nebeneinander stehenden Spalten der gewählten Zeile (oder der Zeile um die gewählte Spalte) ein Griff `.ov-resize`: Ziehen stellt die Zeile in der Vorschau live um, Loslassen schreibt einen Verlaufsschritt (`onResize` in `editor.js` → `setStyle`), Pfeiltasten verschieben um fünf Punkte und behalten den Fokus. Ausgangswert sind die gemessenen Spaltenbreiten. Im Design-Bedienfeld gibt es dazu das Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen). Kein Griff bei gestapelten Spalten und während ein Element gezogen wird.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 512 von 512 grün, mit PHP 8.2 512 grün mit der vorbestehenden Warnung in `UpdaterTest`; neu `tests/ColumnWidthsTest.php` (Spuren, Stapeln je Gerät, Auffüllen und Kappen, Ablehnung ungültiger Werte). `npm test` 33 grün (Relevanz nur für Zeilen). `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden – neu darin: echter Mauszug am Griff verbreitert die erste Spalte, der Wert steht im Bedienfeld der Zeile, auf „Mobil“ gibt es keinen Griff, ein Rückgängig stellt gleiche Spalten her (`reports/editor/column-widths.png`). php-cs-fixer ohne Befund. Ein erster Lauf des Checks scheiterte an `openGroup`, das im Skript erst später definiert ist – die Gruppe wird jetzt an Ort und Stelle geöffnet.
|
||||
|
||||
**Nebenbei:** `.claude/launch.json` trägt in der Arbeitskopie den noch nicht committeten Eintrag `maler-drews` einer anderen Sitzung auf Port 8091; die Pilotkonfiguration ist deshalb lokal auf 8092 verschoben, die Datei bleibt aus diesem Commit heraus.
|
||||
|
||||
## 1.15.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.1` (Commit `d05dba3`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.1 – eigene Absenderadresse je Formular.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.1 v1.15.1`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.1.zip` (4,7 MB, SHA-256 `e15a7a69f299c16d86d372b65610b52f15b40b93904fa6402ea6991de7ed21f7`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.0 → 1.15.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.1`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare eine Absenderadresse der eigenen Domain eintragen und eine Testanfrage senden.
|
||||
|
||||
## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen.
|
||||
|
||||
**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft.
|
||||
|
||||
**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren.
|
||||
|
||||
## 1.15.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
|
||||
|
||||
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ var/releases/install.php → Release-Anhang
|
||||
- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
|
||||
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
|
||||
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
|
||||
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
|
||||
- **Inzwischen umgesetzt:** der Installer (`/install`, ADR 041) und der Weg ohne Git über das Releasearchiv (`scripts/install.php`, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.
|
||||
|
||||
## Übergang auf 1.7.0: `.env` gehört zur Installation
|
||||
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
"test:accordion": "node scripts/accordion-check.mjs",
|
||||
"test:motion": "node scripts/motion-check.mjs",
|
||||
"test:archive-update": "node scripts/archive-update-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
+3
-7
@@ -14,13 +14,9 @@ RewriteRule ^ index.php [L]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
|
||||
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
|
||||
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
|
||||
#RewriteCond %{HTTP_HOST} !^www\. [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^localhost [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
|
||||
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,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.
|
||||
|
||||
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
|
||||
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
|
||||
|
||||
@@ -35,7 +35,7 @@ const TOOLS = [
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
@@ -205,6 +205,7 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
placeResizers();
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
@@ -213,6 +214,124 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Column widths. Umut: "am besten per Drag and Drop im Editor".
|
||||
//
|
||||
// The border between two columns of the selected row is a handle. Dragging it moves share from
|
||||
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
|
||||
// result is written to the document once, when the pointer is released. The arrow keys do the
|
||||
// same in steps of five, so the widths are reachable without a mouse.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
const resizers = [];
|
||||
let focusResizer = null;
|
||||
|
||||
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
|
||||
function resizableRow() {
|
||||
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
|
||||
if (!item) return null;
|
||||
if (item.block.type === 'row') return item.block;
|
||||
return item.parent?.type === 'row' ? item.parent : null;
|
||||
}
|
||||
|
||||
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
|
||||
function sharesOf(rowId) {
|
||||
const kids = childElements(rowId);
|
||||
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
|
||||
}
|
||||
|
||||
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
|
||||
function shift(shares, index, delta) {
|
||||
const pair = shares[index] + shares[index + 1];
|
||||
const least = Math.min(10, pair / 2);
|
||||
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
|
||||
const next = shares.slice();
|
||||
next[index] = left;
|
||||
next[index + 1] = pair - left;
|
||||
return next;
|
||||
}
|
||||
|
||||
const tenth = share => Math.round(share * 10) / 10;
|
||||
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
|
||||
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
|
||||
|
||||
function placeResizers() {
|
||||
const row = dragging ? null : resizableRow();
|
||||
const kids = row ? childElements(row.id) : [];
|
||||
// Stacked columns - a phone, or a single-column layout - have no border to drag.
|
||||
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
|
||||
const wanted = sideBySide ? kids.length - 1 : 0;
|
||||
while (resizers.length > wanted) resizers.pop().remove();
|
||||
while (resizers.length < wanted) resizers.push(resizeButton());
|
||||
const width = 14 / getScale();
|
||||
resizers.forEach((button, index) => {
|
||||
const left = kids[index].box, right = kids[index + 1].box;
|
||||
button.dataset.row = row.id;
|
||||
button.dataset.index = String(index);
|
||||
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = `${label}: ziehen oder Pfeiltasten`;
|
||||
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
|
||||
button.style.top = `${Math.min(left.top, right.top)}px`;
|
||||
button.style.width = `${width}px`;
|
||||
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
|
||||
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function resizeButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-resize';
|
||||
button.addEventListener('pointerdown', startResize);
|
||||
button.addEventListener('keydown', event => {
|
||||
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const shares = sharesOf(rowId);
|
||||
if (!shares) return;
|
||||
focusResizer = { row: rowId, index };
|
||||
onResize(rowId, formatShares(shift(shares, index, step)));
|
||||
});
|
||||
layer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (event.button !== 0) return;
|
||||
const button = event.currentTarget;
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const start = sharesOf(rowId);
|
||||
const node = doc()?.getElementById(rowId);
|
||||
if (!start || !node) return;
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
layer.classList.add('is-resizing');
|
||||
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
|
||||
// width of the columns without the gaps between them.
|
||||
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
const startX = event.clientX;
|
||||
let current = start;
|
||||
const move = moved => {
|
||||
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
|
||||
node.style.gridTemplateColumns = tracksOf(current);
|
||||
sync();
|
||||
};
|
||||
const finish = () => {
|
||||
button.removeEventListener('pointermove', move);
|
||||
button.removeEventListener('pointerup', finish);
|
||||
button.removeEventListener('pointercancel', finish);
|
||||
layer.classList.remove('is-resizing');
|
||||
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
|
||||
else sync();
|
||||
};
|
||||
button.addEventListener('pointermove', move);
|
||||
button.addEventListener('pointerup', finish);
|
||||
button.addEventListener('pointercancel', finish);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@ const node = (tag, className, text) => {
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
|
||||
@@ -248,6 +248,23 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/**
|
||||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||||
*/
|
||||
const fractionsControl = (id, value, apply) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.value = value ?? '';
|
||||
input.placeholder = 'gleich breit';
|
||||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||||
input.setAttribute('inputmode', 'decimal');
|
||||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberControl = (id, value, property, apply) => {
|
||||
const wrap = element('div', 'number-control');
|
||||
const slider = document.createElement('input');
|
||||
@@ -531,6 +548,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"iconcard",
|
||||
"stat",
|
||||
"quote",
|
||||
"cta"
|
||||
"cta",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
|
||||
@@ -55,8 +55,12 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
||||
* single one, directly to a section: that is how a button or a short text gets centred and
|
||||
* narrowed without a one-column row around it. Umut asked for exactly that.
|
||||
*/
|
||||
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
@@ -117,16 +121,21 @@ export function createBlock(type) {
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
|
||||
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
|
||||
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
|
||||
if (type === 'postlist') upgradeDocument(document, 18);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'menu') upgradeDocument(document, 16);
|
||||
if (type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
|
||||
if (type === 'slider') upgradeDocument(document, 17);
|
||||
if (type === 'social') upgradeDocument(document, 23);
|
||||
}
|
||||
|
||||
@@ -189,7 +198,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||
if (inside && !isContainer(target.block)) return false;
|
||||
if (inside && !canContain(target.block, source.block)) return false;
|
||||
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
|
||||
// ein Akkordeoneintrag nicht.
|
||||
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
|
||||
source.siblings.splice(source.index, 1);
|
||||
const destination = locate(document.blocks, targetId);
|
||||
if (inside) (destination.block.children ??= []).push(source.block);
|
||||
@@ -220,10 +231,18 @@ function place(document, block, parentId, index) {
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
/**
|
||||
* Inserts a new block at an exact position instead of behind the selection.
|
||||
*
|
||||
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
|
||||
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
|
||||
* zu sehen.
|
||||
*/
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
const block = createBlock(type);
|
||||
if (!dropList(document, parentId, block)) return null;
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), parentId, index);
|
||||
return place(document, block, parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,12 +348,19 @@ export function mediaProps(media) {
|
||||
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
if (target?.type === 'image') {
|
||||
upgradeDocument(document, 3);
|
||||
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
|
||||
return target.id;
|
||||
}
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
|
||||
const id = insertBlock(document, 'gallery', selected);
|
||||
if (id === null) return null;
|
||||
parent = locate(document.blocks, id).block;
|
||||
}
|
||||
upgradeDocument(document, 3);
|
||||
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||
return parent.id;
|
||||
}
|
||||
@@ -395,17 +421,19 @@ function buildRow(columns) {
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
const row = buildRow(columns);
|
||||
if (!dropList(document, parentId, row)) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
return place(document, row, parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
if (!target) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), target.parentId, target.index);
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
@@ -574,6 +602,7 @@ export function relevantProperty(block, key, property) {
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
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 (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;
|
||||
}
|
||||
@@ -291,8 +291,14 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* The border between two columns of the selected row: a grab area with a short bar in it. */
|
||||
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
|
||||
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
|
||||
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
|
||||
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
|
||||
+27
-9
@@ -123,12 +123,20 @@ function controls() {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
// Ausruecken heisst: neben den Elternblock. Was dort nicht stehen darf (eine Spalte, ein
|
||||
// Akkordeoneintrag), bleibt, wo es ist.
|
||||
$('#outdent').disabled ||= !current.parent || !dropList(history.document, locate(history.document.blocks, current.parent.id)?.parent?.id ?? null, current.block);
|
||||
$('#go-parent').disabled ||= !current.parent;
|
||||
}
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
// Waehrend des Speicherns ist alles gesperrt - und danach nur das wieder frei, was diese
|
||||
// Sperre geschlossen hat. Das Design-Bedienfeld schaltet Knoepfe gezielt ab ("Gestaltung
|
||||
// einfuegen" ohne Zwischenablage), und die muessen abgeschaltet bleiben.
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => {
|
||||
if (busy) { if (!input.disabled) { input.disabled = true; input.dataset.busyLock = '1'; } }
|
||||
else if (input.dataset.busyLock) { input.disabled = false; delete input.dataset.busyLock; }
|
||||
});
|
||||
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
@@ -281,7 +289,7 @@ function renderContent(block, panel) {
|
||||
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||||
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = value ?? '';
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min ?? '1'; input.max = options.max ?? '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
let previousChoice = input.value;
|
||||
@@ -339,7 +347,7 @@ function renderContent(block, panel) {
|
||||
if (current) current.props.text = markup;
|
||||
}, `rich-${block.id}`);
|
||||
controls(); schedulePreview();
|
||||
}, { label: 'Text' });
|
||||
}, { label: 'Text', plain: history.document.schemaVersion < 12 });
|
||||
const label = document.createElement('label'); label.textContent = 'Text';
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
|
||||
@@ -431,11 +439,11 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'progress') {
|
||||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, min: 0, max: 100, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'slider') {
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, min: 0, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
|
||||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||||
@@ -582,7 +590,6 @@ function renderAdvanced(block, panel) {
|
||||
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
|
||||
if (!mayScript) {
|
||||
scriptInput.readOnly = true;
|
||||
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +718,9 @@ function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
if (!items.length || busy) return;
|
||||
selected = history.change(doc => insertMedia(doc, target, items)); changed();
|
||||
const id = history.change(doc => insertMedia(doc, target, items));
|
||||
if (id === null) { notify('Hier lassen sich keine Bilder einfügen – wähle eine Sektion, Spalte oder Galerie.', true); return; }
|
||||
selected = id; changed();
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -1325,7 +1334,9 @@ $('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
// Derselbe Weg wie ein Zug im Baum, mit denselben Regeln: neben den Elternblock, sofern der
|
||||
// Grosselternblock den Baustein nimmt.
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); moveBlockTo(doc, selected, parent.parent?.id ?? null, parent.index + 1); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
@@ -1347,6 +1358,13 @@ overlay = createCanvasOverlay({
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
|
||||
@@ -19,6 +19,10 @@ Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dat
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
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.
|
||||
@@ -10,3 +10,7 @@
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-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:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-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}
|
||||
@@ -39,7 +39,7 @@ function button(label, title, onClick, className = 'rt-button') {
|
||||
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
|
||||
* what to do with it (history entry, preview refresh).
|
||||
*/
|
||||
export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
export function createRichText(value, apply, { label = 'Text', plain = false } = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'rich-text';
|
||||
|
||||
@@ -55,13 +55,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
surface.setAttribute('role', 'textbox');
|
||||
surface.setAttribute('aria-multiline', 'true');
|
||||
surface.setAttribute('aria-label', label);
|
||||
surface.innerHTML = value ?? '';
|
||||
// Unter Schema v12 ist der Text Klartext: "Preis <b>5 Euro</b>" ist dann eine Zeichenfolge, kein
|
||||
// Markup, und darf beim Oeffnen nicht als HTML gelesen werden.
|
||||
if (plain) surface.textContent = value ?? '';
|
||||
else surface.innerHTML = value ?? '';
|
||||
|
||||
const source = document.createElement('textarea');
|
||||
source.className = 'rt-source';
|
||||
source.hidden = true;
|
||||
source.setAttribute('aria-label', `${label} als HTML`);
|
||||
source.value = value ?? '';
|
||||
source.value = plain ? surface.innerHTML : (value ?? '');
|
||||
|
||||
const push = markup => { source.value = markup; apply(markup); };
|
||||
|
||||
|
||||
+12
-3
@@ -96,6 +96,13 @@ p{hyphens:auto}
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.block-form>.cms-button{justify-self:start}
|
||||
/* Ausrichtung eines Buttons (Design-Token „align"): ein inline-flex-Element zentriert sich nicht
|
||||
über margin:auto, darum hier als Block mit eigener Breite - in Spalten, Zeilen und Rastern. */
|
||||
.kant .cms-button.d-desktop-align-left{justify-self:start;margin-inline:0}
|
||||
.kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}
|
||||
.kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}
|
||||
@media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
@media(max-width:650px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
|
||||
@@ -376,9 +383,11 @@ body.consent-open{padding-bottom:320px}
|
||||
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
|
||||
what a picture or a colour field from edge to edge needs. A full width section drops its own
|
||||
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
|
||||
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
|
||||
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
/* Mit ".kant" davor, weil das Design ".kant .shell{max-width:var(--k-container)}" schreibt: ohne
|
||||
den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */
|
||||
.kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none}
|
||||
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
|
||||
/* A section that is a flex container centres its content box; the inner container must then be
|
||||
allowed to grow, or a full height section would show a strip in the middle. */
|
||||
|
||||
@@ -67,7 +67,7 @@ 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', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
|
||||
$include = ['bin', 'config', 'migrations', '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'];
|
||||
|
||||
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
|
||||
|
||||
@@ -253,6 +253,39 @@ try {
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
// Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The border
|
||||
// between the two columns is a handle; dragging it to the right widens the first column.
|
||||
await treeSelect(firstColumnId);
|
||||
await frame.locator(`#${firstColumnId}`).scrollIntoViewIfNeeded();
|
||||
const resizeHandle = page.locator('.ov-resize');
|
||||
await resizeHandle.waitFor();
|
||||
assert.equal(await resizeHandle.count(), 1, 'two columns have one border to drag');
|
||||
const columnTracks = () => frame.locator(`#${newSectionId} .block-row`).first().evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').map(Number.parseFloat));
|
||||
const evenTracks = await columnTracks();
|
||||
assert.ok(Math.abs(evenTracks[0] - evenTracks[1]) < 2, `columns start out equal: ${evenTracks}`);
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2 + 120, handleBox.y + handleBox.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await previewReady();
|
||||
const widenedTracks = await columnTracks();
|
||||
assert.ok(widenedTracks[0] > widenedTracks[1] * 1.3, `dragging the border widened the first column: ${widenedTracks}`);
|
||||
await page.screenshot({ path: 'reports/editor/column-widths.png', fullPage: true });
|
||||
// The same value is visible as numbers in the design panel of the row.
|
||||
await treeSelect(await frame.locator(`#${newSectionId} .block-row`).first().getAttribute('id'));
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
const layoutGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: 'Layout' }) }).first();
|
||||
if (!(await layoutGroup.evaluate(element => element.open))) await layoutGroup.locator('summary').click();
|
||||
assert.match(await page.locator('#style-columnWidths').inputValue(), /^\d+(\.\d)? \d+(\.\d)?$/, 'shares are stored per column');
|
||||
// On a phone the columns stack, so there is no border to drag - and one undo restores equal columns.
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
await treeSelect(firstColumnId);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.ov-resize').length === 0);
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
const restoredTracks = await columnTracks();
|
||||
assert.ok(Math.abs(restoredTracks[0] - restoredTracks[1]) < 2, `undo restores equal columns: ${restoredTracks}`);
|
||||
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
||||
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
||||
const publicBefore = await (await fetch(origin)).text();
|
||||
@@ -344,14 +377,15 @@ try {
|
||||
assert.match((await rowState()).title, /gehört in eine Sektion/);
|
||||
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
|
||||
|
||||
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
||||
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// In einer Sektion werden Zeile und Spalte moeglich - eine einzelne Spalte darf seit 1.16.1
|
||||
// direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente). Die Sektion kommt aus
|
||||
// dem Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// einem anderen Dokument.
|
||||
await tab('Struktur');
|
||||
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
||||
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
|
||||
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
||||
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Sektion moeglich');
|
||||
|
||||
// Und in der Zeile wird die Spalte moeglich.
|
||||
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ function kant_checks(string $target): array
|
||||
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
|
||||
$free = @disk_free_space($target);
|
||||
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
|
||||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Maler-Drews-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/maler-drews.db"
|
||||
export MEDIA_DIRECTORY="$root/var/maler-drews-media"
|
||||
rm -f "$root/var/maler-drews.db"
|
||||
rm -rf "$root/var/maler-drews-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/maler-drews-assets}"
|
||||
"$php" -d memory_limit=512M scripts/seed-maler-drews.php "$assets"
|
||||
# Lato und Cormorant Garamond vom eigenen Server. Der Abruf schreibt fonts.css nur fuer dieses
|
||||
# Design neu; die Datei teilen sich hier aber alle Installationen (Kant-IT und Elektro Bernhard
|
||||
# brauchen Lato 900). Deshalb nur holen, wenn die Cormorant-Dateien fehlen. Das PHP 8.4 hier kennt
|
||||
# keine Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
|
||||
if [ -f "$root/public/assets/fonts/cormorant-garamond-700-latin.woff2" ]; then
|
||||
echo "Schriften liegen bereits unter public/assets/fonts."
|
||||
elif [ -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
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Inhalte der Website „Ihr Malerservice Drews“, am 15.09.2026 von maler-drews.com übernommen.
|
||||
* Erzeugt aus dem HTML der laufenden Website; Wortlaut unverändert, nur Auszeichnung bereinigt.
|
||||
* Wird von seed-maler-drews.php eingelesen.
|
||||
*/
|
||||
|
||||
return [
|
||||
'impressum' => '<p>Axel Drews<br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach</p> <p>Telefon: +49 7224 40821<br>Telefax: +49 7224 990503<br>E-Mail: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a><br></p> <p><strong>Umsatzsteuer-ID:</strong><br>Umsatzsteuer-Identifikationsnummer nach §27a Umsatzsteuergesetz: DE205838338</p> <p><strong>Hinweis gemäß Online-Streitbeilegungs-Verordnung</strong></p> <p>Nach geltendem Recht sind wir verpflichtet, Verbraucher auf die Existenz der Europäischen Online-Streitbeilegungs-Plattform hinzuweisen, die für die Beilegung von Streitigkeiten genutzt werden kann, ohne dass ein Gericht eingeschaltet werden muss. Für die Einrichtung der Plattform ist die Europäische Kommission zuständig. Die Europäische Online-Streitbeilegungs-Plattform ist hier zu finden: <a href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</a>. Unsere E-Mail lautet: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a></p> <p>Wir weisen aber darauf hin, dass wir nicht bereit sind, uns am Streitbeilegungsverfahren im Rahmen der Europäischen Online-Streitbeilegungs-Plattform zu beteiligen. Nutzen Sie zur Kontaktaufnahme bitte unsere obige E-Mail und Telefonnummer.</p> <p><strong>Disclaimer – rechtliche Hinweise</strong></p> <p><strong>§ 1 Warnhinweis zu Inhalten</strong></p> <p>Die kostenlosen und frei zugänglichen Inhalte dieser Webseite wurden mit größtmöglicher Sorgfalt erstellt. Der Anbieter dieser Webseite übernimmt jedoch keine Gewähr für die Richtigkeit und Aktualität der bereitgestellten kostenlosen und frei zugänglichen journalistischen Ratgeber und Nachrichten. Namentlich gekennzeichnete Beiträge geben die Meinung des jeweiligen Autors und nicht immer die Meinung des Anbieters wieder. Allein durch den Aufruf der kostenlosen und frei zugänglichen Inhalte kommt keinerlei Vertragsverhältnis zwischen dem Nutzer und dem Anbieter zustande, insoweit fehlt es am Rechtsbindungswillen des Anbieters.</p> <p><strong>§ 2 Externe Links</strong></p> <p>Diese Website enthält Verknüpfungen zu Websites Dritter („externe Links“). Diese Websites unterliegen der Haftung der jeweiligen Betreiber. Der Anbieter hat bei der erstmaligen Verknüpfung der externen Links die fremden Inhalte daraufhin überprüft, ob etwaige Rechtsverstöße bestehen. Zu dem Zeitpunkt waren keine Rechtsverstöße ersichtlich. Der Anbieter hat keinerlei Einfluss auf die aktuelle und zukünftige Gestaltung und auf die Inhalte der verknüpften Seiten. Das Setzen von externen Links bedeutet nicht, dass sich der Anbieter die hinter dem Verweis oder Link liegenden Inhalte zu Eigen macht. Eine ständige Kontrolle der externen Links ist für den Anbieter ohne konkrete Hinweise auf Rechtsverstöße nicht zumutbar. Bei Kenntnis von Rechtsverstößen werden jedoch derartige externe Links unverzüglich gelöscht.<br><br><strong>§ 3 Urheber- und Leistungsschutzrechte<br></strong><br>Die auf dieser Website veröffentlichten Inhalte unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede vom deutschen Urheber- und Leistungsschutzrecht nicht zugelassene Verwertung bedarf der vorherigen schriftlichen Zustimmung des Anbieters oder jeweiligen Rechteinhabers. Dies gilt insbesondere für Vervielfältigung, Bearbeitung, Übersetzung, Einspeicherung, Verarbeitung bzw. Wiedergabe von Inhalten in Datenbanken oder anderen elektronischen Medien und Systemen. Inhalte und Rechte Dritter sind dabei als solche gekennzeichnet. Die unerlaubte Vervielfältigung oder Weitergabe einzelner Inhalte oder kompletter Seiten ist nicht gestattet und strafbar. Lediglich die Herstellung von Kopien und Downloads für den persönlichen, privaten und nicht kommerziellen Gebrauch ist erlaubt. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.<br><br><strong>§ 4 Besondere Nutzungsbedingungen<br></strong><br>Soweit besondere Bedingungen für einzelne Nutzungen dieser Website von den vorgenannten Paragraphen abweichen, wird an entsprechender Stelle ausdrücklich darauf hingewiesen. In diesem Falle gelten im jeweiligen Einzelfall die besonderen Nutzungsbedingungen. </p>',
|
||||
'datenschutz' => [
|
||||
'<p>Wir informieren Sie nachfolgend gemäß den gesetzlichen Vorgaben des Datenschutzrechts (insb. gemäß BDSG n.F. und der europäischen Datenschutz-Grundverordnung ‚DS-GVO‘) über die Art, den Umfang und Zweck der Verarbeitung personenbezogener Daten durch unser Unternehmen. Diese Datenschutzerklärung gilt auch für unsere Websites und Sozial-Media-Profile. Bezüglich der Definition von Begriffen wie etwa „personenbezogene Daten“ oder „Verarbeitung“ verweisen wir auf Art. 4 DS-GVO.</p> <strong>Name und Kontaktdaten des / der Verantwortlichen</strong><br> Unser/e Verantwortliche/r (nachfolgend „Verantwortlicher“) i.S.d. Art. 4 Zif. 7 DS-GVO ist:<br> <br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach<br>Geschäftsführer: Axel Drews <br>Tel: +49 7224 40821 <br>Fax: +49 7224 990503<br>E-Mail-Adresse: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a> <br><br> <strong>Datenarten, Zwecke der Verarbeitung und Kategorien betroffener Personen</strong><br> <p>Nachfolgend informieren wir Sie über Art, Umfang und Zweck der Erhebung, Verarbeitung und Nutzung personenbezogener Daten.</p> <strong>1. Arten der Daten, die wir verarbeiten</strong><br>Nutzungsdaten (Zugriffszeiten, besuchte Websites etc.), Kontaktdaten (Telefonnummer, E-Mail, Fax etc.), Kommunikationsdaten (IP-Adresse etc.), <br> <br> <strong>2. Zwecke der Verarbeitung nach Art. 13 Abs. 1 c) DS-GVO</strong><br>Website technisch und wirtschaftlich optimieren, Leichten Zugang zur Website ermöglichen, Erfüllung gesetzlicher Aufbewahrungspflichten, Optimierung und statistische Auswertung unserer Dienste, Vermeidung von SPAM und Missbrauch, Kontaktanfragen abwickeln, <br> <br> <strong>3. Kategorien der betroffenen Personen nach Art. 13 Abs. 1 e) DS-GVO</strong><br>Besucher/Nutzer der Website, Kunden, Bewerber, <br> <br> <p>Die betroffenen Personen werden zusammenfassend als „Nutzer“ bezeichnet.</p> <br> <strong>Rechtsgrundlagen der Verarbeitung personenbezogener Daten</strong> <p>Nachfolgend Informieren wir Sie über die Rechtsgrundlagen der Verarbeitung personenbezogener Daten:</p> <ol> <li>Wenn wir Ihre Einwilligung für die Verarbeitung personenbezogenen Daten eingeholt haben, ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Ihre Anfrage hin erfolgen, so ist Art. 6 Abs. 1 S. 1 lit. b) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung einer rechtlichen Verpflichtung erforderlich, der wir unterliegen (z.B. gesetzliche Aufbewahrungspflichten), so ist Art. 6 Abs. 1 S. 1 lit. c) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung erforderlich, um lebenswichtige Interessen der betroffenen Person oder einer anderen natürlichen Person zu schützen, so ist Art. 6 Abs. 1 S. 1 lit. d) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Wahrung unserer oder der berechtigten Interessen eines Dritten erforderlich und überwiegen diesbezüglich Ihre Interessen oder Grundrechte und Grundfreiheiten nicht, so ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO Rechtsgrundlage.</li> </ol> <br> <strong>Weitergabe personenbezogener Daten an Dritte und Auftragsverarbeiter</strong> <p>Ohne Ihre Einwilligung geben wir grundsätzlich keine Daten an Dritte weiter. Sollte dies doch der Fall sein, dann erfolgt die Weitergabe auf der Grundlage der zuvor genannten Rechtsgrundlagen z.B. bei der Weitergabe von Daten an Online-Paymentanbieter zur Vertragserfüllung oder aufgrund gerichtlicher Anordnung oder wegen einer gesetzlichen Verpflichtung zur Herausgabe der Daten zum Zwecke der Strafverfolgung, zur Gefahrenabwehr oder zur Durchsetzung der Rechte am geistigen Eigentum.<br> Wir setzen zudem Auftragsverarbeiter (externe Dienstleister z.B. zum Webhosting unserer Websites und Datenbanken) zur Verarbeitung Ihrer Daten ein. Wenn im Rahmen einer Vereinbarung zur Auftragsverarbeitung an die Auftragsverarbeiter Daten weitergegeben werden, erfolgt dies immer nach Art. 28 DS-GVO. Wir wählen dabei unsere Auftragsverarbeiter sorgfältig aus, kontrollieren diese regelmäßig und haben uns ein Weisungsrecht hinsichtlich der Daten einräumen lassen. Zudem müssen die Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen getroffen haben und die Datenschutzvorschriften gem. BDSG n.F. und DS-GVO einhalten</p> <br> <strong>Datenübermittlung in Drittstaaten</strong> <p>Durch die Verabschiedung der europäischen Datenschutz-Grundverordnung (DS-GVO) wurde eine einheitliche Grundlage für den Datenschutz in Europa geschaffen. Ihre Daten werden daher vorwiegend durch Unternehmen verarbeitet, für die DS-GVO Anwendung findet. Sollte doch die Verarbeitung durch Dienste Dritter außerhalb der Europäischen Union oder des Europäischen Wirtschaftsraums stattfinden, so müssen diese die besonderen Voraussetzungen der Art. 44 ff. DS-GVO erfüllen. Das bedeutet, die Verarbeitung erfolgtLine truncated
|
||||
'<strong>Kontaktaufnahme per Kontaktformular / E-Mail / Fax / Post</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Kontaktformular, Fax, Post oder E-Mail werden Ihre Angaben zum Zwecke der Abwicklung der Kontaktanfrage verarbeitet.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Daten ist bei Vorliegen einer Einwilligung von Ihnen Art. 6 Abs. 1 S. 1 lit. a) DS-GVO. Rechtsgrundlage für die Verarbeitung der Daten, die im Zuge einer Kontaktanfrage oder E-Mail, eines Briefes oder Faxes übermittelt werden, ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Der Verantwortliche hat ein berechtigtes Interesse an der Verarbeitung und Speicherung der Daten, um Anfragen der Nutzer beantworten zu können, zur Beweissicherung aus Haftungsgründen und um ggf. seiner gesetzlichen Aufbewahrungspflichten bei Geschäftsbriefen nachkommen zu können. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 S. 1 lit. b) DS-GVO.<br><br></li> <li>Wir können Ihre Angaben und Kontaktanfrage in unserem Customer-Relationship-Management System („CRM System“) oder einem vergleichbaren System speichern.<br><br></li> <li>Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus der Eingabemaske des Kontaktformulars und diejenigen, die per E-Mail übersandt wurden, ist dies dann der Fall, wenn die jeweilige Konversation mit Ihnen beendet ist. Beendet ist die Konversation dann, wenn sich aus den Umständen entnehmen lässt, dass der betroffene Sachverhalt abschließend geklärt ist. Anfragen von Nutzern, die über einen Account bzw. Vertrag mit uns verfügen, speichern wir bis zum Ablauf von zwei Jahren nach Vertragsbeendigung. Im Fall von gesetzlichen Archivierungspflichten erfolgt die Löschung nach deren Ablauf: Ende handelsrechtlicher (6 Jahre) und steuerrechtlicher (10 Jahre) Aufbewahrungspflicht.<br><br></li> <li>Sie haben jederzeit die Möglichkeit, die Einwilligung nach Art. 6 Abs. 1 S. 1 lit. a) DS-GVO zur Verarbeitung der personenbezogenen Daten zu widerrufen. Nehmen Sie per E-Mail Kontakt mit uns auf, so können Sie der Speicherung der personenbezogenen Daten jederzeit widersprechen.<br><br></li> </ol><br> <strong>Kontaktaufnahme per Telefon</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Telefon wird Ihre Telefonnummer zur Bearbeitung der Kontaktanfrage und deren Abwicklung verarbeitet und temporär im RAM / Cache des Telefongerätes / Displays gespeichert bzw. angezeigt. Die Speicherung erfolgt aus Haftungs- und Sicherheitsgründen, um den Beweis des Anrufs führen zu können sowie aus wirtschaftlichen Gründen, um einen Rückruf zu ermöglichen. Im Falle von unberechtigten Werbeanrufen, sperren wir die Rufnummern.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Telefonnummer ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 lit. b) DS-GVO.<br><br></li> <li>Der Gerätecache speichert die Anrufe Tage und überschreibt bzw. löscht sukzessiv alte Daten, bei Entsorgung des Gerätes werden alle Daten gelöscht und der Speicher ggf. zerstört. Gesperrte Telefonnummer werden jährlich auf die Notwendigkeit der Sperrung geprüft.<br><br></li> <li>Sie können die Anzeige der Telefonnummer verhindern, indem Sie mit unterdrückter Telefonnummer anrufen.<br><br></li> </ol><br> <strong>Google Analytics</strong> <ol> <li>Wir haben das Webseitenanalyse-Tool „Google Analytics“ (<strong>Dienstanbieter:</strong> Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) auf unserer Website integriert.<br><br></li> <li><strong>Datenkategorien und Beschreibung der Datenverarbeitung:</strong> User-ID, IP-Adresse (anonymisiert). Beim Besuch unserer Website setzt Google einen Cookie auf Ihren Computer, um die Benutzung unserer Website durch Sie analysieren zu können. Wir haben die IP-Anonymisierung „anonymizeIP“ aktiviert, wodurch die IP-Adressen nur gekürzt weiterverarbeitet werden. Auf dieser Webseite wird Ihre IP-Adresse von Google daher innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum zuvor gekürzt. Nur in Ausnahmefällen wird die volle IP-Adresse an einen Server von Google in den USA übertragen und dort gekürzt. Im Auftrag des Betreibers dieser Webseite wird Google diese Informationen benutzen, um Ihre Nutzung der Webseite auszuwerten, um Reports über die Webseitenaktivitäten zusammenzustellen und um weitere, mit der Websitenutzung und der Internetnutzung verbundene, Dienstleistungen gegenüber dem Verantwortlichen zu erbringen. Wir haben darüber hinaus die geräteübergreifende Analyse von Website-Besuchern aktiviert, die über eine sog. User-ID durchgeführt wird. Die im Rahmen von GoogleLine truncated
|
||||
'<strong>Google Maps</strong> <ol> <li>Wir haben auf unserer Website Karten von „Google Maps“ (<strong>Anbieter</strong>: Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) integriert.<br><br></li> <li><strong>Datenkategorie und Beschreibung der Datenverarbeitung:</strong> Nutzungsdaten (z.B. IP, Standort, aufgerufene Seite). Mit Google Maps können wir den Standort von Adressen und eine Anfahrtsbeschreibung direkt auf unserer Website in interaktiven Karten anzeigen und Ihnen die Nutzung dieses Tools ermöglichen. Bei dem Abruf unserer Website, wo Google Maps integriert ist, wird eine Verbindung zu den Servern von Google in den USA aufgebaut. Hierbei können Ihre IP und Standort an Google übertragen werden. Zudem erhält Google die Information, dass Sie die entsprechende Seite aufgerufen haben. Dies erfolgt auch ohne Nutzerkonto bei Google. Sollten Sie in Ihren Google-Account eingeloggt sein, kann Google die obigen Daten Ihrem Account zuordnen. Wenn Sie dies nicht wünschen, müssen Sie sich bei Ihrem Google-Account ausloggen. Google erstellt aus solchen Daten Nutzerprofile und nutzt diese Daten zum Zwecke der Werbung, Marktforschung oder Optimierung seiner Websites.<br><br></li> <li><strong>Zweck der Verarbeitung:</strong> Bereitstellung einer nutzerfreundlichen, wirtschaftlichen und optimierten Webseite.<br><br></li> <li><strong>Rechtsgrundlagen:</strong> Haben Sie für Verarbeitung Ihrer personenbezogenen Daten mittels „Google Maps“ vom Drittanbieter Ihre Einwilligung erteilt („Opt-in“), dann ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO die Rechtsgrundlage. Rechtsgrundlage ist zudem unser in den obigen Zwecken liegendes berechtigtes Interesse an der Datenverarbeitung nach Art. 6 Abs. 1 S.1 lit. f) DS-GVO.<br><br></li> <li><strong>Datenübermittlung/Empfängerkategorie:</strong> Drittanbieter in den USA.<br><br></li> <li><strong>Speicherdauer:</strong> Cookies bis zu 6 Monate oder bis zur Löschung durch Sie. Ansonsten sobald sie nicht mehr für die Verarbeitungszwecke benötigt werden.<br><br></li> <li><strong>Widerspruchs- und Beseitigungsmöglichkeit:</strong> Sie haben gegenüber Google ein Widerspruchsrecht gegen die Bildung von Nutzerprofilen. Bitte richten Sie sich deswegen direkt an Google über die unten genannte Datenschutzerklärung. Ein Opt-Out-Widerspruch hinsichtlich der Werbe-Cookies können Sie hier in Ihrem Google-Account vornehmen:<br><a href="https://adssettings.google.com/authenticated">https://adssettings.google.com/authenticated</a>.<br><br></li> <li>In den Nutzungsbedingungen von Google Maps unter <a href="https://www.google.com/intl/de_de/help/terms_maps.html">https://www.google.com/intl/de_de/help/terms_maps.html</a> und in der Datenschutzerklärung für Werbung von Google unter <a href="https://policies.google.com/technologies/ads">https://policies.google.com/technologies/ads</a> finden Sie weitere Informationen zur Verwendung von Google Cookies und deren Werbetechnologien, Speicherdauer, Anonymisierung, Standortdaten, Funktionsweise und Ihre Rechte. Allgemeine Datenschutzerklärung von Google: <a href="https://policies.google.com/privacy">https://policies.google.com/privacy</a>.<br><br></li> </ol><br> <strong>Datenschutz bei Bewerbungen und im Bewerberverfahren</strong> <ol> <li>Bewerbungen, die auf elektronischem Wege oder per Post an den Verantwortlichen gesendet werden, werden zum Zwecke der Abwicklung des Bewerberverfahrens elektronisch oder manuell verarbeitet.<br><br></li> <li>Wir weisen ausdrücklich darauf hin, dass Bewerbungsunterlagen mit „besonderen Kategorien personenbezogener Daten“ nach Art. 9 DS-GVO (z.B. ein Foto, welches Rückschlüsse auf Ihre ethnische Herkunft, Religion oder Ihren Familienstand gibt), mit Ausnahme einer eventuellen Schwerbehinderung, welche Sie aus freier Entscheidung offenlegen möchten, unerwünscht sind. Sie sollen Ihre Bewerbung ohne diese Daten einreichen. Dies hat keine Auswirkungen auf Ihre Bewerberchancen.<br><br></li> <li>Rechtsgrundlagen für die Verarbeitung sind Art. 6 Abs. 1 S.1 lit. b) DS-GVO sowie § 26 BDSG n.F.<br><br></li> <li>Wird nach Abschluss des Bewerberverfahrens, ein Arbeitsverhältnis mit dem Bewerber / der Bewerberin eingegangen, werden die Bewerberdaten unter Beachtung einschlägiger Datenschutzvorschriften gespeichert. Wird Ihnen nach Abschluss des Bewerberverfahrens keine Stelle angeboten, so wird Ihr eingereichtes Bewerbungsschreiben samt Unterlagen 6 Monate nach Versand der Absage gelöscht, um etwaigen Ansprüchen und Nachweispflichten nach AGG genügen zu können.<br><br></li> </ol><br> <b>Rechte der betroffenen Person</b> <ol> <li><strong>Widerspruch oder Widerruf gegen die Verarbeitung Ihrer Daten<br><br> Soweit die Verarbeitung auf Ihrer Einwilligung gemäß Art. 6 Abs. 1 S. 1 lit. a), Art. 7 DS-GVO beruht, haben Sie das Recht, die Einwilligung jederzeit zu widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten VLine truncated
|
||||
],
|
||||
'beitraege' => [
|
||||
[
|
||||
'slug' => '60-jahre-ihr-malerservice-drews-ein-jubilaeum-mit-tradition-und-zukunft',
|
||||
'kind' => 'news',
|
||||
'title' => '60 Jahre Ihr Malerservice Drews – Ein Jubiläum mit Tradition und Zukunft',
|
||||
'date' => '2025-10-17 09:36',
|
||||
'excerpt' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'description' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'content' => '<p>In diesem Jahr feiern wir ein ganz besonderes Ereignis: <strong>60 Jahre Ihr Malerservice Drews</strong>!<br>Seit unserer Gründung im Jahr 1965 steht unser Familienbetrieb für Qualität, Zuverlässigkeit und Kreativität rund um alle Maler- und Lackierarbeiten. Was einst im Loffenauer Milchhäusle begann, hat sich über sechs Jahrzehnte zu einem modernen Handwerksunternehmen mit einem starken Team und zufriedenen Kundinnen und Kunden in der gesamten Region entwickelt.</p> <p>Wir freuen uns sehr, dass unser Jubiläum auch in der regionalen Presse aufgegriffen wurde. Der Artikel gibt spannende Einblicke in unsere Geschichte, unsere Philosophie und die Leidenschaft, mit der wir täglich an die Arbeit gehen.</p> <p><strong>Ein herzliches Dankeschön</strong> gilt all unseren Kundinnen, Kunden, Geschäftspartnern und unserem großartigen Team – ohne Sie wäre dieser Erfolg nicht möglich gewesen.<br>Mit Stolz blicken wir auf 60 erfolgreiche Jahre zurück und mit Tatkraft und Freude in die Zukunft!</p>',
|
||||
'images' => ['bnn.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-2',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-10-19 14:44',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-0008-scaled.jpg', 'img-4658.jpg', 'img-0007-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-3',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2021-12-14 12:12',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2021-12-14-13-02-09.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-44',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade 44',
|
||||
'date' => '2020-08-03 12:29',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21-2-1.jpg', 'photo-2020-08-03-11-31-21-2.jpg', 'photo-2020-08-03-11-31-21-3.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Aussenfassade Fachwerkhaus',
|
||||
'date' => '2020-08-03 12:33',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade Sanierung',
|
||||
'date' => '2021-04-08 06:28',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['gse-buetthof-bilder.jpg', 'img-0103-scaled.jpg', 'img-0104-scaled.jpg', 'img-0118-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-09-14 06:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-12-09-50-01-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Bad',
|
||||
'date' => '2020-10-19 14:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['a934b725-d7dc-4d23-a967-a8ceab1a7268.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'betriebsferien-vom-11-08-bis-22-08',
|
||||
'kind' => 'news',
|
||||
'title' => 'Betriebsferien vom 11.08. bis 22.08.',
|
||||
'date' => '2025-07-23 05:25',
|
||||
'excerpt' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'description' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'content' => '<p>Liebe Kundinnen und Kunden,<br>bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August Betriebsferien machen. In dieser Zeit ist unser Betrieb geschlossen. Ab dem 25. August sind wir wieder wie gewohnt für Sie da.<br><br>Vielen Dank für Ihr Verständnis und eine schöne Sommerzeit!<br>Ihr Malerservice Drews<br><br>Danke!</p>',
|
||||
'images' => ['betriebsferien.jpg', 'betriebsferien-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'decke-zierleisten',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Rekonstruktion Decken Zierleiste',
|
||||
'date' => '2021-02-24 06:52',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2021-02-24-06-47-34-3.jpg', 'photo-2021-02-24-06-47-34.jpg', 'photo-2021-02-24-06-47-34-1.jpg', 'photo-2021-02-24-06-47-34-2.jpg', 'photo-2021-02-24-06-48-19.jpg', 'photo-2021-02-24-06-48-191.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'dekoration',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Dekoration',
|
||||
'date' => '2020-08-03 12:20',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-29-15.jpg', 'photo-2020-08-03-11-31-21-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'exklusiv-in-mittelbaden-wir-sind-offizieller-little-greene-partner',
|
||||
'kind' => 'news',
|
||||
'title' => 'Exklusiv in Mittelbaden: Wir sind offizieller Little Greene Partner',
|
||||
'date' => '2025-04-11 17:44',
|
||||
'excerpt' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'description' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'content' => '<p>Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner im Raum Mittelbaden für den traditionsreichen Farben- und Tapetenhersteller Little Greene ist.<br>Little Greene steht seit jeher für hochwertige sowie umweltfreundliche Farben mit beeindruckender Farbtiefe, historische Farbkollektionen mit britischem Flair und handverlesene Tapetendesigns mit Stil und Charakter.</p> <p>Als exklusiver Partner in der Region Mittelbaden bieten wir Ihnen nicht nur Zugang zu den Produkten von Little Greene, sondern auch eine umfassende Beratung und professionelle Verarbeitung direkt vom Fachbetrieb. Ob Neubau, Altbausanierung oder Innenraumgestaltung – wir setzen Ihre Wünsche mit hochwertigen Materialien und einem Sinn für Ästhetik um.<br>Auf unserem Instagram-Account erhalten Sie regelmäßig neue Inspiration für Ihre Raumgestaltung mit Farben und Tapeten von Little Greene.</p>',
|
||||
'images' => ['photo-2025-04-11-14-47-35.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fensterladen-verzierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fensterladen Verzierung',
|
||||
'date' => '2021-12-01 14:02',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-5893-scaled.jpg', 'img-5869.jpg', 'img-0136-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'freilegen-einer-backsteinwand',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Freilegen einer Backsteinwand',
|
||||
'date' => '2023-02-16 07:37',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['1676472540643-scaled.jpg', '1676472540620-scaled.jpg', '1676472540632-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'friedrichsbad-baden-baden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Glanzvoll renoviert: Malerarbeiten im historischen Friedrichsbad, Baden‑Baden',
|
||||
'date' => '2025-07-30 09:48',
|
||||
'excerpt' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'description' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'content' => '<p>Im ehrwürdigen <strong>Friedrichsbad in Baden‑Baden</strong>, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, wurden die Wände und Decken behutsam restauriert und neu gestaltet. Mit fein abgestimmten Farbtönen und hochwertigen Techniken hat Malermeister Drews die prachtvollen Majolika-Kacheln, die eleganten Stuckelemente und die beeindruckende Kuppelarchitektur gekonnt in Szene gesetzt – ohne den historischen Charakter zu verfälschen.</p> <p>Das Ergebnis: ein stimmiger Mix aus eleganter Modernisierung und historischer Ästhetik, der das Bad nicht nur technisch aufwertet, sondern auch den Besuchern eine visuelle Reise durch vergangene Epochen ermöglicht. Gerade in Bereichen wie Eingangsbereich, Kuppelsaal und Umkleiden wurde mit viel Feingefühl gearbeitet, um die subtile Balance zwischen Restaurierung und zeitgemäßem Farbkonzept zu wahren.</p> <p>Ob renovierte Wandflächen, frische Texturen oder veredelte Holz- und Metallbereiche – das Team hat dem Friedrichsbad ein innenarchitektonisches Gesicht verliehen, das Geschichte atmet und gleichzeitig einladend wirkt.</p>',
|
||||
'images' => ['kopie-von-2024-06-24-2.jpg', 'kopie-von-2024-06-24-4.jpg', 'kopie-von-2024-06-24-3.jpg', 'kopie-von-2024-06-24-5.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'frohe-weihnachten-und-einen-guten-start-ins-neue-jahr',
|
||||
'kind' => 'news',
|
||||
'title' => 'Frohe Weihnachten und einen guten Start ins neue Jahr!',
|
||||
'date' => '2025-11-23 19:33',
|
||||
'excerpt' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'description' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'content' => '<p>Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr.</p> <p>Der gesamte Malerbetrieb wünscht Ihnen und Ihren Liebsten besinnliche Feiertage, erholsame Stunden und einen farbenfrohen Start in das Jahr 2026.</p> <p>**Betriebsferien**</p> <p>Unser Betrieb bleibt vom **22.12.2025 bis einschließlich 06.01.2026** geschlossen.</p> <p>Ab dem **07.01.2026** sind wir wie gewohnt wieder für Sie erreichbar und freuen uns darauf, Ihre Projekte mit frischen Ideen und handwerklicher Qualität umzusetzen.</p> <p>Wir wünschen Ihnen eine wundervolle Weihnachtszeit! 🎄</p>',
|
||||
'images' => ['image0.jpeg', 'image0-1.jpeg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad-und-boden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad und Boden',
|
||||
'date' => '2020-08-04 08:32',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-16-36-12.jpg', 'photo-2020-08-03-16-36-12-2.jpg', 'photo-2020-08-03-16-36-12-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad',
|
||||
'date' => '2020-12-01 22:14',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106-1.jpg', 'img-20201130-155129-resized-20201130-035353784.jpg', 'img-20201130-155118-resized-20201130-035352490.jpg', 'img-20201130-155109-resized-20201130-035353150.jpg', '44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'grund-und-hauptschule',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Grund- und Hauptschule',
|
||||
'date' => '2020-08-06 08:46',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['binarydata-big-256998.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'kirche',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Kirche',
|
||||
'date' => '2020-07-20 12:56',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['malerservice-drews-112-von-116.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'modernisierung-neugestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Modernisierung & Neugestaltung',
|
||||
'date' => '2021-11-29 13:55',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['img-5922-scaled.jpg', 'img-5921-scaled.jpg', 'img-5923-scaled.jpg', 'img-5924-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Fachwerkhaus',
|
||||
'date' => '2020-09-08 04:54',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-07-10-06-38-5-1.jpg', 'photo-2020-09-07-10-06-49.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-wohnzimmer-mit-kaminofen',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Wohnzimmer mit Kaminofen',
|
||||
'date' => '2020-11-20 13:06',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-11-19-18-30-50.jpg', 'photo-2020-11-19-18-30-46-1.jpg', 'photo-2020-11-19-18-30-46.jpg', 'photo-2020-11-19-18-30-47.jpg', 'photo-2020-11-19-18-30-48-1.jpg', 'photo-2020-11-19-18-30-48.jpg', 'photo-2020-11-19-18-30-49-1.jpg', 'photo-2020-11-19-18-30-49.jpg', 'photo-2020-11-19-18-30-50-1.jpg', 'photo-2020-11-19-18-30-51-1.jpg', 'photo-2020-11-19-18-30-51.jpg', 'photo-2020-11-19-18-30-52.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'schulkindbetreuung-hilpertsau',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Unterwasserzauber – Malerarbeiten in der Schulkindbetreuung Hilpertsau',
|
||||
'date' => '2025-07-30 09:38',
|
||||
'excerpt' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'description' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'content' => '<p>Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll gestalteten Wandmalereien in eine fantasievolle Unterwasserwelt verwandelt. Seepferdchen, bunte Fische und wellenförmige Elemente erwecken die Wände und Decken zum Leben und laden Kinder zum Staunen und Entdecken ein.</p> <p>Die kreative Gestaltung stammt vom renommierten Malermeisterbetrieb Drews aus Gernsbach – sie schafft nicht nur optische Freude, sondern fördert auch die kindliche Vorstellungskraft und Spielmotivationen</p>',
|
||||
'images' => ['kopie-von-2024-04-19-4.jpg', 'kopie-von-2024-04-19-3.jpg', 'kopie-von-2024-04-19-5.jpg', 'kopie-von-2024-04-19-6.jpg', 'kopie-von-img-1398.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sommerfest-2024',
|
||||
'kind' => 'news',
|
||||
'title' => 'Sommerfest 2024',
|
||||
'date' => '2024-09-28 15:37',
|
||||
'excerpt' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'description' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'content' => '<p>Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.<br>Zu Beginn des Festes richtete unser Geschäftsführer Axel Drews einige Worte an die Anwesenden. In seiner Rede bedankte er sich bei allen für ihren unermüdlichen Einsatz und die stetige Bereitschaft, das Unternehmen mit vollem Engagement zu unterstützen. Danach folgte der Höhepunkt des Tages: Die Ehrung langjähriger Mitarbeiter.</p> <p>Drei unserer geschätzten Kollegen wurden für ihre beeindruckende Betriebszugehörigkeit geehrt und mit einer Urkunde sowie einem liebevoll zusammengestellten Präsentkorb bedacht. Eine besonders bemerkenswerte Auszeichnung erhielt Dennis Bähring, der bereits seit unglaublichen 30 Jahren Teil unseres Unternehmens ist. Dennis begann seine Laufbahn bei uns mit seiner Ausbildung und ist seitdem ein fester Bestandteil unseres Teams. In seiner langen Zeit bei uns hat er zahlreiche Projekte und Veränderungen miterlebt. Sein Engagement, seine Loyalität und sein Fachwissen sind für uns von unschätzbarem Wert.</p> <p>Andreas Vetter und Jan Kassner wurden ebenfalls für ihre langjährige Treue zum Unternehmen gewürdigt. Beide blicken auf stolze 20 Jahre Betriebszugehörigkeit zurück. In dieser Zeit haben sie durch ihre tägliche Arbeit maßgeblich zum Erfolg des Betriebs beigetragen. Sie haben sich stets als verlässliche und engagierte Mitarbeiter erwiesen, und es war uns eine Ehre, ihnen für ihre Loyalität und ihren Einsatz zu danken.</p> <p>Das Sommerfest bot den idealen Rahmen, um diese besonderen Jubiläen zu feiern und den Teamgeist noch weiter zu stärken. Bei leckerem Essen, erfrischenden Getränken und geselligen Gesprächen genossen alle Anwesenden den restlichen Abend. Es war ein gelungenes Fest, das nicht nur als Dankeschön für die Mitarbeit der vergangenen Monate diente, sondern auch als Erinnerung daran, wie wichtig das gemeinsame Miteinander in unserem Unternehmen ist.</p> <p>Noch einmal herzlichen Glückwunsch an unsere Jubilare und ein großes Dankeschön an alle Mitarbeiterinnen und Mitarbeiter für euer tägliches Engagement und eure wertvolle Arbeit! Wir freuen uns darauf, gemeinsam weiterhin erfolgreich in die Zukunft zu gehen.</p>',
|
||||
'images' => ['photo-2024-09-27-15-22-38-1.jpg', 'photo-2024-09-27-15-22-38.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'treppenhaus-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Treppenhaus Sanierung',
|
||||
'date' => '2022-02-24 13:27',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['image-50379521-scaled.jpg', 'image-50438401-scaled.jpg', 'image-50424065-scaled.jpg', 'image-50386433-scaled.jpg', 'image-50382337-scaled.jpg', 'treppenhaus-axel-2-scaled.jpg', 'treppenhaus-axel-3-scaled.jpg', 'treppenhaus-axel-4-scaled.jpg', 'treppenhaus-axel-1-scaled.jpg', 'treppenhaus-axel-5-scaled.jpg', 'treppenhaus-axel-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'verkleidung-einer-fassade-aus-nf-brettern',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Verkleidung einer Fassade aus N+F Brettern',
|
||||
'date' => '2023-12-19 06:23',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['img-0594-scaled.jpg', 'screenshot-2023-12-18-084035.jpg', 'pfarramt-fassade.png', 'img-0597-scaled.jpg', 'img-0595-scaled.jpg', 'img-0596-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'von-abgenutzt-zu-atemberaubend-die-verwandlung-einer-tuer-durch-restauration-und-malerei',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Von Abgenutzt zu Atemberaubend: Die Verwandlung einer Tür durch Restauration und Malerei!',
|
||||
'date' => '2023-08-17 09:26',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '<p><a href="https://www.facebook.com/photo/?fbid=308837308169469&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837158169484&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837301502803&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837224836144&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837141502819&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p>',
|
||||
'images' => ['1692193874729-scaled.jpg', '1692193874743-scaled.jpg', 'img-9452-scaled.jpg', '1692193874974-scaled.jpg', '1692193874955-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wand-beton-optik',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wand Beton Optik',
|
||||
'date' => '2022-03-09 07:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['4e4c6616-c9bb-4660-a3f5-d3617f2f9ddb-1.jpg', '41bad44a-d3b5-4859-a6ae-447dd5de5fc6.jpg', '64c6dc8d-8425-441c-8838-fe9dda248c4e.jpg', 'img-1047-scaled.jpg', 'img-1045-scaled.jpg', 'img-1046-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wappen-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wappen Sanierung',
|
||||
'date' => '2020-10-01 11:11',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-29-09-15-34-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wohnzimmer-gestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wohnzimmer Gestaltung',
|
||||
'date' => '2022-02-28 15:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['wohnzimmergestaltung-2.jpg'],
|
||||
],
|
||||
],
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Environment;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Zeigt die öffentliche Adresse der Website oder ändert sie.
|
||||
*
|
||||
* Die Adresse (`SITE_ORIGIN`) steht in `.env`, weil sie zur Installation gehört und nicht zum
|
||||
* Inhalt: Der Installer schreibt sie einmal, danach gab es nur den Editor für die Datei. Dieser
|
||||
* Befehl prüft, was hineinkommt - mit Schema, ohne Pfad -, und schreibt es an dieselbe Stelle.
|
||||
*
|
||||
* Mit oder ohne www ist damit eine Entscheidung an einem Ort: Canonical, Sitemap, Feed und die
|
||||
* Umleitung der anderen Schreibweise folgen dieser Einstellung.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:site:origin', description: 'Zeigt die öffentliche Adresse der Website (SITE_ORIGIN) oder ändert sie.')]
|
||||
final class SiteOriginCommand extends Command
|
||||
{
|
||||
public function __construct(private string $projectDir, private string $siteOrigin)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('adresse', InputArgument::OPTIONAL, 'Die neue Adresse mit Schema, z. B. https://www.meine-firma.de. Ohne Angabe wird die aktuelle gezeigt.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$neu = trim((string) $input->getArgument('adresse'));
|
||||
if ('' === $neu) {
|
||||
$io->writeln(sprintf('Die Website ist eingetragen als <info>%s</info>.', '' === $this->siteOrigin ? '(leer)' : $this->siteOrigin));
|
||||
$io->writeln('Ändern: <comment>php bin/console cms:site:origin https://www.meine-firma.de</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$neu = self::normalize($neu);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Die Website ist jetzt eingetragen als %s.', $neu));
|
||||
$io->writeln('Canonical, Sitemap, Feed und strukturierte Daten nennen diese Adresse; die Schreibweise '.(str_starts_with(parse_url($neu, PHP_URL_HOST) ?: '', 'www.') ? 'ohne' : 'mit').' www leitet dorthin um.');
|
||||
$io->writeln('Falls die Website den alten Wert noch kennt: <comment>php bin/console cache:clear --env=prod</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema und Host, kleingeschrieben, ohne Pfad, Abfrage oder abschließenden Schrägstrich.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function normalize(string $adresse): string
|
||||
{
|
||||
$adresse = rtrim(trim($adresse), '/');
|
||||
$teile = parse_url($adresse);
|
||||
$schema = strtolower((string) ($teile['scheme'] ?? ''));
|
||||
if (!\in_array($schema, ['http', 'https'], true)) {
|
||||
throw new \InvalidArgumentException('Die Adresse beginnt mit https:// – zum Beispiel https://www.meine-firma.de');
|
||||
}
|
||||
$host = strtolower((string) ($teile['host'] ?? ''));
|
||||
if ('' === $host || false === filter_var($schema.'://'.$host, FILTER_VALIDATE_URL) || isset($teile['user'], $teile['pass'])) {
|
||||
throw new \InvalidArgumentException('Diese Adresse ist keine gültige URL.');
|
||||
}
|
||||
if (isset($teile['path']) && '' !== $teile['path'] || isset($teile['query']) || isset($teile['fragment'])) {
|
||||
throw new \InvalidArgumentException('Nur Schema und Domain, ohne Pfad: https://www.meine-firma.de statt https://www.meine-firma.de/start');
|
||||
}
|
||||
|
||||
return $schema.'://'.$host.(isset($teile['port']) ? ':'.$teile['port'] : '');
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,8 @@ final class CustomCode
|
||||
// so "& .block-text" must stay "#id .block-text" instead of becoming
|
||||
// "#id.block-text", which would target the element itself.
|
||||
$suffix = preg_replace('/\s+/', ' ', rtrim($match[1][0])) ?? '';
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 ,.:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
// Kein Komma: `&,body{...}` wuerde zu `#id,body{...}` und verliesse das Element.
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
$rules[$suffix] = $this->declarations($match[2][0]);
|
||||
|
||||
@@ -164,6 +164,11 @@ final class FormRepository
|
||||
if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.');
|
||||
}
|
||||
// Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse.
|
||||
$sender = trim((string) ($settings['sender'] ?? ''));
|
||||
if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.');
|
||||
}
|
||||
$replyTo = trim((string) ($settings['replyToField'] ?? ''));
|
||||
if ('' !== $replyTo) {
|
||||
$match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']);
|
||||
@@ -174,6 +179,7 @@ final class FormRepository
|
||||
|
||||
$clean = [
|
||||
'recipient' => $recipient,
|
||||
'sender' => $sender,
|
||||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||||
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
|
||||
'replyToField' => $replyTo,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -36,7 +37,7 @@ final class PageRepository
|
||||
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
|
||||
$path = $slug;
|
||||
if (null !== $parentId) {
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ?', [$parentId, 'default', $locale]);
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $locale]);
|
||||
if (false === $parent) {
|
||||
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
||||
}
|
||||
@@ -244,13 +245,19 @@ final class PageRepository
|
||||
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
|
||||
*/
|
||||
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
|
||||
{
|
||||
return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion);
|
||||
}
|
||||
|
||||
/** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */
|
||||
public function revisionDocument(string $pageId, string $revisionId): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||
if (false === $raw) {
|
||||
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
|
||||
}
|
||||
|
||||
return $this->saveDraft($pageId, json_decode($raw, true, 512, JSON_THROW_ON_ERROR), $expectedVersion);
|
||||
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +273,15 @@ final class PageRepository
|
||||
'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)',
|
||||
['default', gmdate('Y-m-d H:i:s')],
|
||||
) as $row) {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
try {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (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.
|
||||
error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage()));
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
||||
$published[] = $row['path'];
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
// A share of nothing is a column that cannot be seen, so zero is refused with the rest.
|
||||
'fractions' => $this->require(
|
||||
1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true),
|
||||
sprintf('"%s" is not a list of column shares.', $key),
|
||||
),
|
||||
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
@@ -112,6 +117,9 @@ final class StyleCompiler
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
if ('row' === ($block['type'] ?? null)) {
|
||||
$css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? []));
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
@@ -196,13 +204,13 @@ final class StyleCompiler
|
||||
if ('' !== $burger) {
|
||||
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
|
||||
// The change eases in, so a header does not snap from glass to solid.
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($values['scrolled'], 'self', $device) : '';
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
|
||||
if ('' !== $scrolled) {
|
||||
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
|
||||
}
|
||||
@@ -319,6 +327,26 @@ final class StyleCompiler
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung
|
||||
* vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 <farbe>` und waere unsichtbar.
|
||||
*/
|
||||
private function inheritShadow(array $state, array $base): array
|
||||
{
|
||||
foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) {
|
||||
if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) {
|
||||
continue;
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($state[$part]) && isset($base[$part])) {
|
||||
$state[$part] = $base[$part];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
{
|
||||
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
|
||||
@@ -461,6 +489,55 @@ final class StyleCompiler
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the
|
||||
* wider device like every other style. A device whose layout stacks the columns - "one" from
|
||||
* the classic tokens or a single grid column - gets one track instead: the desktop rule carries
|
||||
* an id and would otherwise outrank the class that stacks the columns on a phone.
|
||||
*/
|
||||
private function columnTracks(string $id, array $style, array $layout, int $count): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$widths = null;
|
||||
$stacked = false;
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = is_array($style[$device] ?? null) ? $style[$device] : [];
|
||||
$widths = $values['columnWidths'] ?? $widths;
|
||||
if (isset($values['gridColumns'])) {
|
||||
$stacked = 1 === (int) $values['gridColumns'];
|
||||
} elseif (isset($layout['design'][$device]['columns'])) {
|
||||
$stacked = 'one' === $layout['design'][$device]['columns'];
|
||||
}
|
||||
if (null === $widths || $count < 2) {
|
||||
continue;
|
||||
}
|
||||
$rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count));
|
||||
if ($rule === $previous) {
|
||||
continue;
|
||||
}
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule)
|
||||
: $rule;
|
||||
$previous = $rule;
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** One track per column: a column added later gets the average share, surplus shares are dropped. */
|
||||
private function tracks(string $widths, int $count): string
|
||||
{
|
||||
$shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0));
|
||||
$shares = array_slice($shares, 0, $count);
|
||||
$average = [] === $shares ? 1.0 : array_sum($shares) / count($shares);
|
||||
while (count($shares) < $count) {
|
||||
$shares[] = $average;
|
||||
}
|
||||
$format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.');
|
||||
|
||||
return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares));
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
|
||||
@@ -27,6 +27,8 @@ final class StyleSchema
|
||||
*/
|
||||
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
/** Column shares of a row, up to twelve numbers separated by blanks: "2 1" or "66.7 33.3". */
|
||||
public const FRACTIONS = '/^\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}$/D';
|
||||
|
||||
/**
|
||||
* group – where the property appears in the editor
|
||||
@@ -152,6 +154,9 @@ final class StyleSchema
|
||||
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
|
||||
]],
|
||||
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
|
||||
// Die Anteile der Spalten einer Zeile: "2 1" ist zwei Drittel zu einem Drittel. Gesetzt wird
|
||||
// das vor allem durch Ziehen der Spaltengrenze in der Vorschau des Editors.
|
||||
'columnWidths' => ['group' => 'layout', 'type' => 'fractions', 'css' => null, 'label' => 'Spaltenbreiten'],
|
||||
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
|
||||
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
|
||||
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
|
||||
|
||||
@@ -34,11 +34,33 @@ final class ConsentController extends AbstractController
|
||||
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
|
||||
};
|
||||
$response = $this->redirect($this->backTo($request));
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
// 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.
|
||||
if ($this->sameSite($request)) {
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Ob die Anfrage von dieser Website kommt - nach dem, was der Browser darueber sagt. */
|
||||
private function sameSite(Request $request): bool
|
||||
{
|
||||
$site = $request->headers->get('Sec-Fetch-Site');
|
||||
if (null !== $site) {
|
||||
return \in_array($site, ['same-origin', 'same-site', 'none'], true);
|
||||
}
|
||||
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
||||
if (null === $origin || '' === $origin) {
|
||||
// Aeltere Browser nennen keine Herkunft; ohne Anhaltspunkt bleibt die Frage beantwortbar.
|
||||
return true;
|
||||
}
|
||||
$host = parse_url($origin, PHP_URL_HOST);
|
||||
|
||||
return \is_string($host) && strtolower($host) === strtolower($request->getHost());
|
||||
}
|
||||
|
||||
#[Route('/einwilligung', name: 'consent_page', methods: ['GET'])]
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
@@ -73,7 +95,9 @@ final class ConsentController extends AbstractController
|
||||
private function backTo(Request $request): string
|
||||
{
|
||||
$target = $request->request->getString('back');
|
||||
if ('' === $target || !str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
||||
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
|
||||
// lesen Browser genauso.
|
||||
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,11 @@ final class EditorController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
// Dieselbe Regel wie beim Speichern: Skripte aendert nur ein Administrator - auch nicht
|
||||
// ueber den Umweg einer alten Fassung.
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
|
||||
@@ -21,6 +21,9 @@ final class FormAdminController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
|
||||
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
@@ -37,7 +40,7 @@ final class FormAdminController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
public function save(Request $request, string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('forms', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -62,6 +65,7 @@ final class FormAdminController extends AbstractController
|
||||
$fields,
|
||||
[
|
||||
'recipient' => $request->request->getString('recipient'),
|
||||
'sender' => $request->request->getString('sender'),
|
||||
'subject' => $request->request->getString('subject'),
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
|
||||
@@ -90,8 +90,10 @@ final class FormController extends AbstractController
|
||||
foreach ($form['fields'] as $field) {
|
||||
$lines[] = $field['label'].': '.($values[$field['name']] ?? '');
|
||||
}
|
||||
// Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster
|
||||
// liefern nur aus, was von einer Adresse ihrer eigenen Domain kommt.
|
||||
$email = (new Email())
|
||||
->from($form['settings']['recipient'])
|
||||
->from(($form['settings']['sender'] ?? '') ?: $form['settings']['recipient'])
|
||||
->to($form['settings']['recipient'])
|
||||
->subject($form['settings']['subject'])
|
||||
->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/'));
|
||||
|
||||
@@ -20,6 +20,8 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Ein gespeichertes Menue steht sofort auf der Website - Sache des Veroeffentlichers.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$slot = $request->query->getString('slot', 'header');
|
||||
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
|
||||
$menu = $this->menus->all()[$slot] ?? null;
|
||||
@@ -43,7 +45,7 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ 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));
|
||||
// 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'));
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// 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.
|
||||
@@ -111,7 +115,7 @@ final class PageController extends AbstractController
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
'needsTabs' => $needsTabs,
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
@@ -119,7 +123,7 @@ final class PageController extends AbstractController
|
||||
'needsScrolled' => $needsScrolled,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
'needsZoom' => $needsZoom,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
@@ -145,7 +149,7 @@ 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() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
|
||||
@@ -44,6 +44,9 @@ final class PartialController extends AbstractController
|
||||
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Ein Bereich kennt keinen Entwurf: Speichern ist Veroeffentlichen. Deshalb ist der ganze
|
||||
// Bildschirm Sache des Veroeffentlichers, nicht erst der Speicherknopf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$partials = [];
|
||||
foreach ($this->partials->all() as $partial) {
|
||||
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
|
||||
@@ -56,7 +59,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -81,7 +84,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -97,12 +100,16 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function editor(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function document(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->json($this->payload($this->partial($id)));
|
||||
}
|
||||
|
||||
@@ -110,7 +117,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ final class SeoController extends AbstractController
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
|
||||
// Das juengere der beiden Daten: eine spaeter bearbeitete und neu veroeffentlichte
|
||||
// Seite behielte sonst das Datum ihrer ersten Veroeffentlichung.
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
// Archives exist only while they list something.
|
||||
|
||||
@@ -27,6 +27,10 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Alles hier wirkt sofort auf die Website: 404-Seite und Weiterleitungen sind Sache des
|
||||
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
|
||||
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$choosable = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
@@ -57,7 +61,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -101,7 +105,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -133,7 +137,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
public function legalPages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
@@ -21,7 +21,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)
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ final class SystemController extends AbstractController
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
// Actual runtime connection, with credentials excluded from the screen.
|
||||
'database' => $this->databaseFacts(),
|
||||
// Die Adresse, die Sitemap, Feed und Canonical nennen - und ob die Verwaltung gerade
|
||||
// unter ihr laeuft. Die Schreibweise mit oder ohne www leitet die Anwendung selbst um;
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -114,6 +119,22 @@ final class SystemController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{adresse: string, quelle: string, host: string, passt: bool}
|
||||
*/
|
||||
private function originFacts(Request $request): array
|
||||
{
|
||||
$wanted = strtolower((string) parse_url(trim($this->siteOrigin), PHP_URL_HOST));
|
||||
$port = parse_url(trim($this->siteOrigin), PHP_URL_PORT);
|
||||
|
||||
return [
|
||||
'adresse' => $this->siteOrigin,
|
||||
'quelle' => $this->environmentSource('SITE_ORIGIN'),
|
||||
'host' => $request->getHttpHost(),
|
||||
'passt' => '' !== $wanted && strtolower($request->getHost()) === $wanted && (null === $port || $request->getPort() === $port),
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit server values take precedence over the single installation file. */
|
||||
private function environmentSource(string $name): string
|
||||
{
|
||||
|
||||
@@ -20,6 +20,9 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Das Design gilt sofort auf jeder Seite - das ist Administratorensache, wie die
|
||||
// Rollenhilfe es verspricht.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
@@ -46,7 +49,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
|
||||
public function fonts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
@@ -75,7 +78,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Eine Website, eine Adresse: mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver.
|
||||
*
|
||||
* Bisher stand die Umleitung auskommentiert in public/.htaccess und musste von Hand zu SITE_ORIGIN
|
||||
* passen. Zwei Stellen fuer eine Entscheidung gehen auseinander, und dann zeigt jede Seite in
|
||||
* Canonical, Sitemap und Feed auf eine Adresse, die weiterleitet. Jetzt liest die Anwendung die
|
||||
* konfigurierte Adresse und schickt die andere Schreibweise dorthin - dauerhaft (301), mit Pfad
|
||||
* und Abfrage.
|
||||
*
|
||||
* Was sie bewusst nicht tut: einen fremden Host umleiten. Eine Vorschauadresse des Hosters, eine
|
||||
* IP-Adresse oder localhost bleiben erreichbar, auch wenn SITE_ORIGIN etwas anderes nennt - sonst
|
||||
* sperrt eine falsch eingetragene Adresse jeden aus, samt der Verwaltung. Umgeleitet wird nur das
|
||||
* Paar aus `example.de` und `www.example.de`. Und sie erzwingt kein HTTPS: hinter einem Proxy
|
||||
* sieht PHP oft nur http, und eine Schleife waere schlimmer als fehlendes HTTPS. Die drei Zeilen
|
||||
* dafuer stehen weiter in .htaccess, wo der Webserver die Verbindung wirklich kennt.
|
||||
*/
|
||||
final class CanonicalHostSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Vor Wartung (40) und Installer (36): auf dem falschen Host gibt es genau eine Antwort,
|
||||
// und fuer die braucht niemand die Datenbank.
|
||||
return [KernelEvents::REQUEST => ['onRequest', 48]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
// Nur lesende Anfragen: ein 301 macht aus einem POST ein GET und verwirft die Eingabe.
|
||||
// Ein Formular auf der anderen Schreibweise darf noch ankommen; die Seite dahinter
|
||||
// fuehrt dann ohnehin auf die richtige Adresse.
|
||||
if (!$event->isMainRequest() || !\in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
|
||||
return;
|
||||
}
|
||||
$target = self::target($this->siteOrigin, $request);
|
||||
if (null !== $target) {
|
||||
$event->setResponse(new RedirectResponse($target, 301, ['Cache-Control' => 'public, max-age=3600']));
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Adresse, auf die diese Anfrage gehoert - oder null, wenn sie schon dort ist. */
|
||||
public static function target(string $origin, Request $request): ?string
|
||||
{
|
||||
$parts = parse_url(trim($origin));
|
||||
$wanted = strtolower((string) ($parts['host'] ?? ''));
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
if ('' === $wanted || !\in_array($scheme, ['http', 'https'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$host = strtolower($request->getHost());
|
||||
} catch (\UnexpectedValueException) {
|
||||
// Ein Host, den Symfony nicht annimmt, bekommt seine 400 vom Kernel - nicht von hier.
|
||||
return null;
|
||||
}
|
||||
if ($host === $wanted || !self::wwwTwins($host, $wanted)) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
|
||||
return $scheme.'://'.$wanted.$port.$request->getRequestUri();
|
||||
}
|
||||
|
||||
/** `example.de` und `www.example.de` - und sonst nichts. */
|
||||
private static function wwwTwins(string $a, string $b): bool
|
||||
{
|
||||
return 'www.'.$a === $b || $a === 'www.'.$b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
|
||||
/**
|
||||
* `%env(projectdir:DATABASE_URL)%`: ersetzt `%kernel.project_dir%` in einer Umgebungsvariablen -
|
||||
* und sonst nichts.
|
||||
*
|
||||
* Symfonys `resolve:` tut mehr: es liest jedes `%name%` als Parameter. In einer MySQL-Adresse
|
||||
* stehen Benutzer und Passwort prozentkodiert (`p%40ss` fuer `p@ss`), und `%40ss%` ist dann ein
|
||||
* Parameter, den es nicht gibt - der Container bricht bei jeder Anfrage ab, nachdem der Installer
|
||||
* die Adresse geschrieben hat. Dieser Prozessor kennt nur den einen Platzhalter, den `.env.example`
|
||||
* und der Installer fuer SQLite verwenden.
|
||||
*/
|
||||
final class ProjectDirEnvProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
public function __construct(private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEnv(string $prefix, string $name, \Closure $getEnv): string
|
||||
{
|
||||
$value = $getEnv($name);
|
||||
|
||||
return str_replace('%kernel.project_dir%', $this->projectDir, (string) $value);
|
||||
}
|
||||
|
||||
public static function getProvidedTypes(): array
|
||||
{
|
||||
return ['projectdir' => 'string'];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,66 @@ final class CommandRunner
|
||||
return !in_array('proc_open', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was ein Kindprozess von der Umgebung sehen darf: Pfade, ein Zuhause fuer Composer und Git,
|
||||
* aber kein APP_ENV, das einen Konsolenbefehl gegen die falsche Umgebung laufen liesse.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function environment(): array
|
||||
{
|
||||
$environment = ['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir(), 'TMP' => sys_get_temp_dir()];
|
||||
// Composer verlangt HOME (Linux) oder APPDATA (Windows) - ohne sie bricht `composer
|
||||
// install` ab, waehrend `composer --version` noch laeuft. Git braucht HOME fuer .gitconfig.
|
||||
foreach (['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'COMPOSER_HOME', 'PATHEXT', 'LANG', 'LC_ALL'] as $name) {
|
||||
$value = getenv($name);
|
||||
if (is_string($value) && '' !== $value) {
|
||||
$environment[$name] = $value;
|
||||
}
|
||||
}
|
||||
if (!isset($environment['HOME']) && !isset($environment['USERPROFILE'])) {
|
||||
// Unter php-fpm ist HOME oft leer; ein Ordner in var/ tut es fuer Composer und Git.
|
||||
$environment['HOME'] = sys_get_temp_dir();
|
||||
}
|
||||
if (!isset($environment['COMPOSER_HOME'])) {
|
||||
$environment['COMPOSER_HOME'] = $environment['HOME'] ?? sys_get_temp_dir();
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die PHP-Kommandozeile, mit der Konsolenbefehle laufen.
|
||||
*
|
||||
* Aus einem Webprozess ist PHP_BINARY der Server selbst (php-fpm) oder leer (mod_php) - und
|
||||
* `php-fpm bin/console` startet keinen Befehl. Dann wird die Kommandozeile im Pfad gesucht.
|
||||
*/
|
||||
public function php(): string
|
||||
{
|
||||
if (isset($this->resolved['php'])) {
|
||||
return $this->resolved['php'][0];
|
||||
}
|
||||
$candidates = [];
|
||||
if ('cli' === \PHP_SAPI && '' !== \PHP_BINARY) {
|
||||
$candidates[] = \PHP_BINARY;
|
||||
}
|
||||
$suffix = '\\' === \DIRECTORY_SEPARATOR ? '.exe' : '';
|
||||
foreach ([\PHP_BINDIR.'/php'.$suffix, \PHP_BINDIR.'/php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, 'php'.$suffix, 'php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, \PHP_BINARY] as $candidate) {
|
||||
if ('' !== $candidate && !\in_array($candidate, $candidates, true)) {
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([$candidate, '-r', 'echo PHP_VERSION;'], sys_get_temp_dir(), 30)['ok']) {
|
||||
$this->resolved['php'] = [$candidate];
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0] ?? 'php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $command
|
||||
*
|
||||
@@ -43,7 +103,7 @@ final class CommandRunner
|
||||
$cwd,
|
||||
// A minimal, predictable environment: no inherited APP_ENV that would make a console
|
||||
// command run against the wrong environment.
|
||||
['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir()],
|
||||
$this->environment(),
|
||||
);
|
||||
if (!is_resource($process)) {
|
||||
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
|
||||
@@ -109,7 +169,7 @@ final class CommandRunner
|
||||
}
|
||||
// A phar in the project is the way on hosting where nothing is installed globally.
|
||||
if (is_file($cwd.'/'.$executable.'.phar')) {
|
||||
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
|
||||
$candidates[] = [$this->php(), $cwd.'/'.$executable.'.phar'];
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
|
||||
|
||||
+21
-6
@@ -63,6 +63,10 @@ class Download
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
// Auch eine Weiterleitung bleibt bei HTTPS: sonst pruefte die erste Zeile nur die
|
||||
// erste Adresse, und die Gegenstelle koennte auf http:// umlenken.
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
@@ -96,12 +100,23 @@ class Download
|
||||
|
||||
return null;
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
if (is_string($antwort)) {
|
||||
// Der Dateizeiger folgt Weiterleitungen auch auf http://; deshalb hier von Hand, und
|
||||
// jede Station muss HTTPS sein.
|
||||
$antwort = false;
|
||||
for ($hop = 0; $hop <= 3 && str_starts_with($url, 'https://'); ++$hop) {
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 0, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
$kopf = implode("\n", $http_response_header ?? []);
|
||||
if (!is_string($antwort) || 1 !== preg_match('/^HTTP\/\S+ 3\d\d/m', $kopf) || 1 !== preg_match('/^Location:\s*(\S+)/mi', $kopf, $ziel)) {
|
||||
break;
|
||||
}
|
||||
$url = $ziel[1];
|
||||
$antwort = false;
|
||||
}
|
||||
if (is_string($antwort) && 1 === preg_match('/^HTTP\/\S+ 200/m', implode("\n", $http_response_header ?? []))) {
|
||||
$this->grund = '';
|
||||
|
||||
return $antwort;
|
||||
|
||||
@@ -9,8 +9,10 @@ use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\EventSubscriber\InstallSubscriber;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
|
||||
use Doctrine\Migrations\Configuration\Migration\PhpFile;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
@@ -57,11 +59,22 @@ final class Installation
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
// Die Marke schreibt die Website, sobald ein Konto existiert. Sie zaehlt auch dann, wenn
|
||||
// die Datenbank gerade nicht antwortet: ein gedrehtes Passwort oder eine Wartung beim
|
||||
// Hoster darf den Installer nicht wieder oeffnen - dort koennte sonst jeder eine eigene
|
||||
// Datenbank eintragen und sich zum Administrator machen.
|
||||
if (is_file($this->root.'/'.InstallSubscriber::MARKER)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return (bool) $this->db->fetchOne('SELECT id FROM cms_user LIMIT 1');
|
||||
} catch (\Throwable) {
|
||||
// No database, no table, no installation - all three mean the same here.
|
||||
} catch (TableNotFoundException) {
|
||||
// Keine Tabelle: die Datenbank ist da, aber leer - das ist der Installer-Fall.
|
||||
return false;
|
||||
} catch (\Throwable) {
|
||||
// Keine Verbindung. Vor dem Datenbankschritt ist das normal (es gibt noch keine
|
||||
// eingetragene Datenbank); danach ist es eine Stoerung, keine fehlende Installation.
|
||||
return $this->environmentWritten();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -29,7 +29,7 @@ final class Updater
|
||||
private string $databaseUrl,
|
||||
private CommandRunner $runner,
|
||||
private Connection $db,
|
||||
private string $php = PHP_BINARY,
|
||||
private ?string $php = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -288,7 +288,12 @@ final class Updater
|
||||
if (!$step($steps, 'Rauchtest', $health['ok'], $this->lastLine($health['output']))) {
|
||||
throw new \RuntimeException('health');
|
||||
}
|
||||
} catch (\RuntimeException) {
|
||||
} catch (\Throwable $error) {
|
||||
// Nicht nur die eigenen Abbrueche: jeder Fehler auf dem Weg nimmt den Rueckweg, statt
|
||||
// den Wartungszustand ohne ein Wort stehen zu lassen.
|
||||
if (!$error instanceof \RuntimeException) {
|
||||
$step($steps, 'Unerwarteter Fehler', false, $error->getMessage());
|
||||
}
|
||||
$this->rollback($steps, $from, $migrated ? $backup : null, $step);
|
||||
$this->write($steps, $target, false);
|
||||
|
||||
@@ -438,7 +443,7 @@ final class Updater
|
||||
/** @return array{ok: bool, exit: int, output: string} */
|
||||
private function console(array $arguments, int $timeout): array
|
||||
{
|
||||
return $this->runner->run([$this->php, ...$arguments], $this->root, $timeout);
|
||||
return $this->runner->run([$this->php ??= $this->runner->php(), ...$arguments], $this->root, $timeout);
|
||||
}
|
||||
|
||||
/** The SQLite file behind DATABASE_URL, or null for a server database. */
|
||||
|
||||
@@ -32,8 +32,8 @@ final class UserDirectory
|
||||
|
||||
public const ROLE_HELP = [
|
||||
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, aber keine Benutzer verwalten und keine Skripte setzen.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe bearbeiten und speichern, aber nicht veröffentlichen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
|
||||
];
|
||||
|
||||
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
<label for="recipient-{{ f }}">Empfänger</label>
|
||||
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
|
||||
</div>
|
||||
<div>
|
||||
<label for="sender-{{ f }}">Absender</label>
|
||||
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
|
||||
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ f }}">Betreff der E-Mail</label>
|
||||
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<div class="fields">
|
||||
@@ -273,6 +274,9 @@
|
||||
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="card card-body muted">Unternehmensdaten, Knopf „nach oben“, Spamschutz und Einwilligung ändert nur ein Administrator.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="stack-section">
|
||||
|
||||
@@ -32,20 +32,20 @@
|
||||
{% set entries = [
|
||||
{id: 'pages', route: 'admin_pages', icon: 'pages', label: 'Seiten'},
|
||||
{id: 'posts', route: 'admin_posts', icon: 'pages', label: 'Beiträge'},
|
||||
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche'},
|
||||
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs'},
|
||||
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche', role: 'ROLE_PUBLISHER'},
|
||||
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs', role: 'ROLE_PUBLISHER'},
|
||||
{id: 'media', route: 'admin_media', icon: 'media', label: 'Mediathek'},
|
||||
{id: 'forms', route: 'admin_forms', icon: 'forms', label: 'Formulare'},
|
||||
{id: 'forms', route: 'admin_forms', icon: 'forms', label: 'Formulare', role: 'ROLE_PUBLISHER'},
|
||||
{id: 'statistics', route: 'admin_statistics', icon: 'statistics', label: 'Statistik'}
|
||||
] %}
|
||||
{% for entry in entries %}
|
||||
{% for entry in entries %}{% if entry.role is not defined or is_granted(entry.role) %}
|
||||
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: entry.icon} only %}{{ entry.label }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}{% endfor %}
|
||||
</ul>
|
||||
<p class="nav-label" id="nav-site">WEBSITE</p>
|
||||
<ul class="nav-list" aria-labelledby="nav-site">
|
||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>
|
||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
<dt>Adresse</dt>
|
||||
<dd>
|
||||
{% if origin.adresse %}<code>{{ origin.adresse }}</code>{% else %}<span class="check-bad"><strong>Nicht eingetragen.</strong></span>{% endif %}
|
||||
{% if origin.passt %}
|
||||
<span class="muted">– Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um.</span>
|
||||
{% else %}
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr: <code>php bin/console cms:site:origin https://…</code> oder <code>SITE_ORIGIN</code> in <code>.env</code> ändern.</span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ origin.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
{% else %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main id="main" tabindex="-1">
|
||||
@@ -53,7 +53,7 @@
|
||||
</main>
|
||||
{% if areas.footer|default(null) %}
|
||||
<footer class="site-footer">
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
frames, and a preview must not load anything from outside either. #}
|
||||
{% if not editing|default(false) and consentGate.granted(app.request, props.group) %}
|
||||
<div id="{{ block.id }}" class="block-embed{{ designClasses }}">
|
||||
<iframe src="{{ props.url }}" title="{{ props.title }}" height="{{ props.height }}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
||||
<iframe src="{{ props.url }}" title="{{ props.title }}" height="{{ props.height }}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="fullscreen; picture-in-picture"></iframe>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="{{ block.id }}" class="block-embed embed-blocked{{ designClasses }}">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<title>{{ post.title }}</title>
|
||||
<link>{{ origin }}{{ post.href }}</link>
|
||||
<guid isPermaLink="true">{{ origin }}{{ post.href }}</guid>
|
||||
{% if post.publishedAt %}<pubDate>{{ post.publishedAt|date('r') }}</pubDate>{% endif %}
|
||||
{% if post.publishedAt %}<pubDate>{{ (post.publishedAt ~ ' UTC')|date('r', 'UTC') }}</pubDate>{% endif %}
|
||||
{% if post.excerpt %}<description>{{ post.excerpt }}</description>{% endif %}
|
||||
{% for term in post.terms.category %}<category>{{ term.name }}</category>{% endfor %}
|
||||
</item>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
show the layout, not run code that could change the page under the editor's hands. #}
|
||||
{% if pageScript|default(null) and not editing|default(false) %}<script src="{{ pageScript }}" defer></script>{% endif %}
|
||||
{# A data block, not a script: CSP script-src does not apply to application/ld+json. #}
|
||||
{% for block in structuredData|default([]) %}<script type="application/ld+json">{{ block|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
|
||||
{% for block in structuredData|default([]) %}<script type="application/ld+json">{{ block|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_HEX_TAG') b-or constant('JSON_HEX_AMP'))|raw }}</script>
|
||||
{% endfor %}
|
||||
{% if pageStyles|default('') %}
|
||||
{# In the editor the preview inherits the administration's policy, which forbids inline
|
||||
@@ -104,8 +104,10 @@
|
||||
Ankerpositionierung auf ihre Oberkante, und ein Anker muss im Dokument davor stehen. #}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
<a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a>
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
|
||||
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
|
||||
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a></nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\EventSubscriber\CanonicalHostSubscriber;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Mit oder ohne www entscheidet SITE_ORIGIN - und nur dieses Paar wird umgeleitet.
|
||||
*
|
||||
* Die Gegenprobe ist die wichtigere Hälfte: eine Vorschauadresse des Hosters, eine IP-Adresse oder
|
||||
* ein anderer Host dürfen nicht umgeleitet werden, sonst sperrt eine falsch eingetragene Adresse
|
||||
* jeden aus - samt der Verwaltung.
|
||||
*/
|
||||
final class CanonicalHostTest extends TestCase
|
||||
{
|
||||
/** @return iterable<string, array{string, string, ?string}> */
|
||||
public static function addresses(): iterable
|
||||
{
|
||||
yield 'www geht auf die nackte Domain, mit Pfad und Abfrage' => ['https://example.de', 'https://www.example.de/seite/unter?x=1&y=2', 'https://example.de/seite/unter?x=1&y=2'];
|
||||
yield 'die nackte Domain geht auf www' => ['https://www.example.de', 'https://example.de/', 'https://www.example.de/'];
|
||||
yield 'das Schema kommt aus SITE_ORIGIN, nicht aus der Anfrage' => ['https://example.de', 'http://www.example.de/kontakt', 'https://example.de/kontakt'];
|
||||
yield 'Gross- und Kleinschreibung des Hosts spielt keine Rolle' => ['https://example.de', 'https://WWW.Example.DE/', 'https://example.de/'];
|
||||
yield 'ein Port in SITE_ORIGIN bleibt erhalten' => ['https://example.de:8443', 'https://www.example.de/x', 'https://example.de:8443/x'];
|
||||
yield 'auch die Verwaltung wechselt den Host - ein Host, ein Sitzungscookie' => ['https://example.de', 'https://www.example.de/admin/login', 'https://example.de/admin/login'];
|
||||
yield 'ein abschliessender Schraegstrich in SITE_ORIGIN stoert nicht' => ['https://example.de/', 'https://www.example.de/a', 'https://example.de/a'];
|
||||
yield 'auf der richtigen Adresse passiert nichts' => ['https://example.de', 'https://example.de/seite', null];
|
||||
yield 'auf der richtigen Adresse mit www passiert nichts' => ['https://www.example.de', 'https://www.example.de/', null];
|
||||
yield 'nur das Schema weicht ab: keine Umleitung, das macht der Webserver' => ['https://example.de', 'http://example.de/', null];
|
||||
yield 'eine Subdomain ist kein www-Zwilling' => ['https://example.de', 'https://staging.example.de/', null];
|
||||
yield 'eine fremde Domain bleibt erreichbar' => ['https://example.de', 'https://kunde123.hoster-vorschau.de/', null];
|
||||
yield 'eine IP-Adresse bleibt erreichbar' => ['https://example.de', 'http://127.0.0.1/', null];
|
||||
yield 'localhost bleibt erreichbar' => ['https://example.de', 'http://localhost:8080/', null];
|
||||
yield 'die Entwicklungsadresse mit Port ist ihre eigene' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080/', null];
|
||||
yield 'ein doppeltes www ist die www-Fassung der eingetragenen Adresse und kommt heim' => ['https://www.example.de', 'https://www.www.example.de/', 'https://www.example.de/'];
|
||||
yield 'ein doppeltes www ist kein Zwilling der nackten Domain' => ['https://example.de', 'https://www.www.example.de/', null];
|
||||
yield 'ohne SITE_ORIGIN keine Umleitung' => ['', 'https://www.example.de/', null];
|
||||
yield 'SITE_ORIGIN ohne Schema ist keine Adresse' => ['example.de', 'https://www.example.de/', null];
|
||||
yield 'die Vorlage aus .env.example leitet niemanden um' => ['https://example.de', 'https://neue-website.de/install', null];
|
||||
}
|
||||
|
||||
#[DataProvider('addresses')]
|
||||
public function testTheOtherSpellingIsSentToSiteOrigin(string $origin, string $url, ?string $expected): void
|
||||
{
|
||||
$event = $this->event(Request::create($url));
|
||||
(new CanonicalHostSubscriber($origin))->onRequest($event);
|
||||
$response = $event->getResponse();
|
||||
if (null === $expected) {
|
||||
self::assertNull($response, 'Diese Anfrage darf nicht umgeleitet werden.');
|
||||
|
||||
return;
|
||||
}
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(301, $response->getStatusCode(), 'Dauerhaft: Suchmaschinen sollen die Adresse übernehmen.');
|
||||
self::assertSame($expected, $response->headers->get('Location'));
|
||||
self::assertStringContainsString('max-age=3600', (string) $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function testOnlyReadingRequestsAreRedirected(): void
|
||||
{
|
||||
foreach (['POST', 'PUT', 'DELETE'] as $method) {
|
||||
$event = $this->event(Request::create('https://www.example.de/formular/kontakt', $method));
|
||||
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
|
||||
self::assertNull($event->getResponse(), $method.': ein 301 würde die Eingabe verwerfen.');
|
||||
}
|
||||
$event = $this->event(Request::create('https://www.example.de/', 'HEAD'));
|
||||
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
|
||||
self::assertSame(301, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSubRequestsAreLeftAlone(): void
|
||||
{
|
||||
$event = new RequestEvent($this->createMock(HttpKernelInterface::class), Request::create('https://www.example.de/'), HttpKernelInterface::SUB_REQUEST);
|
||||
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testItAnswersBeforeMaintenanceAndInstaller(): void
|
||||
{
|
||||
$priority = CanonicalHostSubscriber::getSubscribedEvents()['kernel.request'][1];
|
||||
self::assertGreaterThan(40, $priority, 'Vor dem Wartungszustand (40) und dem Installer (36): die Umleitung braucht keine Datenbank.');
|
||||
self::assertLessThan(300, $priority, 'Nach den Kopfzeilen der Verwaltung (300), die keine Antwort setzen.');
|
||||
}
|
||||
|
||||
private function event(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -413,6 +413,17 @@ final class CmsTest extends TestCase
|
||||
self::assertSame('/', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheOtherSpellingOfTheHostIsSentToSiteOrigin(): void
|
||||
{
|
||||
// SITE_ORIGIN der Tests ist https://cms.test - die www-Fassung fuehrt dorthin, mit Pfad
|
||||
// und Abfrage, bevor irgendjemand die Datenbank fragt.
|
||||
$response = $this->request('http://www.cms.test/home?a=1');
|
||||
self::assertSame(301, $response->getStatusCode());
|
||||
self::assertSame('https://cms.test/home?a=1', $response->headers->get('Location'));
|
||||
// Ein fremder Host bleibt erreichbar: eine falsch eingetragene Adresse sperrt niemanden aus.
|
||||
self::assertSame(200, $this->request('http://vorschau.hoster.test/robots.txt')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testCreatedPageStaysPrivateUntilPublishedAndKeepsItsAddress(): void
|
||||
{
|
||||
$id = $this->pages->createPage('ueber-uns', null, 'page', DemoContent::starter('Über uns'));
|
||||
@@ -1431,7 +1442,7 @@ final class CmsTest extends TestCase
|
||||
$ohne = $this->request('/');
|
||||
self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent());
|
||||
self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent());
|
||||
self::assertStringNotContainsString('rel="preload"'." href=\"/assets/fonts", $ohne->getContent());
|
||||
self::assertStringNotContainsString('rel="preload" href="/assets/fonts', $ohne->getContent());
|
||||
|
||||
// Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge
|
||||
// er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der
|
||||
@@ -2036,6 +2047,15 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
|
||||
self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Tabs im Fussbereich: die Seite selbst hat keine, braucht das Skript aber trotzdem - und
|
||||
// eine Richtlinie, die es zulaesst. Vorher blieb der Fuss eine Ankerliste mit blockiertem
|
||||
// Skript.
|
||||
$footer = $this->partials->findByKind('footer');
|
||||
$this->partials->save($footer['id'], [...$footer['document'], 'schemaVersion' => max((int) $footer['document']['schemaVersion'], 7), 'blocks' => [...$footer['document']['blocks'], ...array_slice($document['blocks'], -1)]], (int) $footer['version']);
|
||||
$withFooterTabs = $this->request('/ohne-tabs');
|
||||
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $withFooterTabs->getContent());
|
||||
self::assertStringContainsString("script-src 'self'", (string) $withFooterTabs->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testTabsSurviveSavingAndRejectMisplacedEntries(): void
|
||||
@@ -2075,7 +2095,11 @@ final class CmsTest extends TestCase
|
||||
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
|
||||
$this->publishedPage('offen', 'Offen');
|
||||
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
// Neu veroeffentlicht nach einer Aenderung: lastmod ist das Datum der Fassung, nicht das
|
||||
// der ersten Veroeffentlichung.
|
||||
$edited = $this->pages->findForEditor($post);
|
||||
$this->pages->publish($post, $this->pages->saveDraft($post, $edited['document'], $edited['version']), $edited['version'] + 1);
|
||||
|
||||
$sitemap = $this->request('/sitemap.xml');
|
||||
self::assertSame(200, $sitemap->getStatusCode());
|
||||
@@ -2086,6 +2110,7 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
|
||||
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
|
||||
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
|
||||
self::assertStringContainsString("<loc>https://cms.test/ein-beitrag</loc>\n <lastmod>".gmdate('Y-m-d').'</lastmod>', $xml);
|
||||
// A noindex page and an unpublished page have no business in a sitemap.
|
||||
self::assertStringNotContainsString('/intern', $xml);
|
||||
self::assertStringNotContainsString('/entwurf', $xml);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Domain\InvalidDocument;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The editor writes
|
||||
* the shares of a row's columns; this is what the server makes of them.
|
||||
*/
|
||||
final class ColumnWidthsTest extends TestCase
|
||||
{
|
||||
public function testSharesBecomeTracksAndStackWhereTheLayoutStacks(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'zeile', 'type' => 'row',
|
||||
'layout' => ['design' => ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']]],
|
||||
'style' => ['desktop' => ['columnWidths' => '66.7 33.3']],
|
||||
'children' => [['id' => 'a', 'type' => 'column'], ['id' => 'b', 'type' => 'column']],
|
||||
]]]);
|
||||
// The desktop rule carries an id and would win over the class that stacks a phone, so
|
||||
// the phone gets a single track of its own.
|
||||
self::assertSame(
|
||||
'#zeile{grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)}@media (max-width:767px){#zeile{grid-template-columns:minmax(0,1fr)}}',
|
||||
$css,
|
||||
);
|
||||
}
|
||||
|
||||
public function testSharesArePaddedOrCutToTheColumnsAndCanDifferPerDevice(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'zeile', 'type' => 'row',
|
||||
'style' => [
|
||||
'desktop' => ['columnWidths' => '2 1'],
|
||||
'tablet' => ['columnWidths' => '1 1 1 1'],
|
||||
'mobile' => ['gridColumns' => 1],
|
||||
],
|
||||
'children' => [['id' => 'a', 'type' => 'column'], ['id' => 'b', 'type' => 'column'], ['id' => 'c', 'type' => 'column']],
|
||||
]]]);
|
||||
// A column added after the drag gets the average share; a surplus share is dropped.
|
||||
self::assertStringContainsString('#zeile{grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1.5fr)}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#zeile{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr)}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#zeile{grid-template-columns:minmax(0,1fr)}}', $css);
|
||||
// A single column has nothing to share, and only rows have columns.
|
||||
self::assertSame('', (new StyleCompiler())->compileDocument(['blocks' => [
|
||||
['id' => 'einzeln', 'type' => 'row', 'style' => ['desktop' => ['columnWidths' => '2 1']], 'children' => [['id' => 'a', 'type' => 'column']]],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function testOnlyListsOfPositiveSharesAreAccepted(): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
foreach (['2 1', '66.7 33.3', '25 50 25', '1'] as $good) {
|
||||
$compiler->validate(['desktop' => ['columnWidths' => $good]]);
|
||||
}
|
||||
foreach (['2 0', '2,1', '2 1', ' 2 1', '2fr 1fr', '1 1 1 1 1 1 1 1 1 1 1 1 1', '1000 1', '1.25 1'] as $bad) {
|
||||
try {
|
||||
$compiler->validate(['desktop' => ['columnWidths' => $bad]]);
|
||||
self::fail(sprintf('"%s" darf keine Spaltenaufteilung sein.', $bad));
|
||||
} catch (InvalidDocument $e) {
|
||||
self::assertStringContainsString('column shares', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
-13
@@ -11,6 +11,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Security\UserProvider;
|
||||
@@ -51,7 +52,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
$partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
}
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
|
||||
}
|
||||
}
|
||||
@@ -71,9 +72,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
[$_SERVER, $_ENV] = $this->previous;
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
/** Ein Client je Test (der Kernel startet nur einmal); ein zweiter Benutzer meldet sich im selben an. */
|
||||
private function login(string $username = 'admin', ?KernelBrowser $client = null): KernelBrowser
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client ??= self::createClient();
|
||||
$client->getCookieJar()->clear();
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password']));
|
||||
self::assertResponseRedirects('/admin');
|
||||
@@ -83,6 +86,25 @@ final class EditorSecurityTest extends WebTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function testTheSessionCookieFollowsARenamedAdministration(): void
|
||||
{
|
||||
// Heisst die Verwaltung nicht mehr /admin, muss das Sitzungscookie auf ihren Pfad lauten -
|
||||
// sonst schickt der Browser es nie mit, und jede Anmeldung landet wieder vor der Tuer.
|
||||
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
|
||||
$client = self::createClient();
|
||||
$crawler = $client->request('GET', '/stiller-eingang/login');
|
||||
self::assertResponseIsSuccessful();
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'Fixture-only-password']));
|
||||
self::assertResponseRedirects('/stiller-eingang');
|
||||
// Im Test heisst das Sitzungscookie anders als im Betrieb; der Pfad ist, was zaehlt.
|
||||
$paths = array_map(static fn ($cookie): string => $cookie->getPath(), $client->getCookieJar()->all());
|
||||
self::assertContains('/stiller-eingang', $paths, 'Das Sitzungscookie muss fuer den neuen Pfad gelten.');
|
||||
self::assertNotContains('/admin', $paths);
|
||||
$client->followRedirect();
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('a[href="/stiller-eingang/logout"], form[action="/stiller-eingang/logout"]');
|
||||
}
|
||||
|
||||
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
@@ -108,12 +130,70 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function test404AssignmentRequiresPublicationRights(): void
|
||||
{
|
||||
// Ein Redakteur sieht die Einstellungen gar nicht erst: alles dort wirkt sofort.
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertSelectorNotExists('#not-found-form');
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
// Ein Veroeffentlicher darf die 404-Seite zuordnen, aber die Unternehmensdaten nicht aendern.
|
||||
$this->login('publisher', $client);
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('#not-found-form');
|
||||
self::assertSelectorNotExists('form[action="/admin/settings/company"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer was aendern darf, steht in der Rollenhilfe - und hier, damit es so bleibt.
|
||||
*
|
||||
* Jede Zeile: eine Route, die Rolle, die zu wenig ist, und die Antwort 403. Ohne Anmeldung
|
||||
* fuehrt jede dieser Adressen zur Anmeldung. Die Rollen, die duerfen, pruefen die uebrigen Tests.
|
||||
*/
|
||||
public function testEveryLiveChangeNeedsTheRoleTheRoleHelpNames(): void
|
||||
{
|
||||
$tooLittle = [
|
||||
// Design, Unternehmensdaten, System, Benutzer, endgueltiges Loeschen: Administrator.
|
||||
['publisher', 'GET', '/admin/theme'], ['publisher', 'POST', '/admin/theme/save'], ['publisher', 'POST', '/admin/theme/fonts'],
|
||||
['publisher', 'POST', '/admin/settings/company'],
|
||||
['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'],
|
||||
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
|
||||
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
|
||||
['publisher', 'POST', '/admin/pages/p1/delete'],
|
||||
// Alles, was sofort auf der Website steht: Veroeffentlicher.
|
||||
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
|
||||
['editor', 'GET', '/admin/partials'], ['editor', 'POST', '/admin/partials/new'],
|
||||
['editor', 'GET', '/admin/menus'], ['editor', 'POST', '/admin/menus/header'],
|
||||
['editor', 'GET', '/admin/forms'], ['editor', 'POST', '/admin/forms/neu'],
|
||||
['editor', 'POST', '/admin/pages/p1/schedule'], ['editor', 'POST', '/admin/api/pages/p1/publish'],
|
||||
];
|
||||
$client = null;
|
||||
foreach (['publisher', 'editor'] as $role) {
|
||||
$client = $this->login($role, $client);
|
||||
foreach (array_filter($tooLittle, static fn (array $row): bool => $row[0] === $role) as [, $method, $path]) {
|
||||
$client->request($method, $path);
|
||||
self::assertSame(403, $client->getResponse()->getStatusCode(), sprintf('%s %s %s', $role, $method, $path));
|
||||
}
|
||||
}
|
||||
// Ein Redakteur sieht in der Navigation nur, was er oeffnen darf.
|
||||
$crawler = $client->request('GET', '/admin');
|
||||
self::assertResponseIsSuccessful();
|
||||
$links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href'));
|
||||
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/benutzer'] as $hidden) {
|
||||
self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.');
|
||||
}
|
||||
self::assertContains('/admin/media', $links);
|
||||
// Ohne Anmeldung: keine 403-Auskunft, sondern die Anmeldung.
|
||||
$anonymous = $client;
|
||||
$anonymous->getCookieJar()->clear();
|
||||
foreach ([['GET', '/admin'], ['POST', '/admin/api/pages/p1/draft'], ['POST', '/admin/theme/save'], ['POST', '/admin/menus/header']] as [$method, $path]) {
|
||||
$anonymous->request($method, $path);
|
||||
self::assertResponseRedirects('http://localhost/admin/login', null, $method.' '.$path);
|
||||
}
|
||||
// Und ohne Anmeldung ist nichts geschrieben worden.
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||||
}
|
||||
|
||||
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||
@@ -262,7 +342,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
@@ -293,12 +373,12 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
|
||||
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
|
||||
@@ -322,7 +402,8 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
}
|
||||
|
||||
private function contactForm(): FormRepository
|
||||
/** @param array<string, string> $settings overrides for the mail settings */
|
||||
private function contactForm(array $settings = []): FormRepository
|
||||
{
|
||||
$forms = new FormRepository($this->db);
|
||||
$forms->save('kontakt', 'Kontakt', [
|
||||
@@ -331,7 +412,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'],
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.', ...$settings], 0);
|
||||
|
||||
return $forms;
|
||||
}
|
||||
@@ -380,6 +461,12 @@ final class EditorSecurityTest extends WebTestCase
|
||||
// Post/Redirect/Get, so reloading the result page sends nothing twice.
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location'));
|
||||
// Ohne eigene Absenderadresse geht die Post vom Empfaenger an sich selbst.
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertNotNull($mail);
|
||||
self::assertEmailAddressContains($mail, 'From', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
|
||||
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM form_submission');
|
||||
self::assertNotFalse($row);
|
||||
@@ -396,6 +483,33 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertStringContainsString('Danke.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testAFormMaySendFromItsOwnAddress(): void
|
||||
{
|
||||
// Umut: die Versandadresse soll sich festlegen lassen - bisher war sie immer der
|
||||
// Empfaenger, und manche Hoster nehmen fremde Absender gar nicht erst an.
|
||||
$forms = $this->contactForm(['sender' => 'formular@kant-it.de']);
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertNotNull($mail);
|
||||
self::assertEmailAddressContains($mail, 'From', 'formular@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
|
||||
|
||||
// Eine kaputte Absenderadresse wird abgewiesen, eine leere bedeutet: wie der Empfaenger.
|
||||
try {
|
||||
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => 'kein-postfach'], 1);
|
||||
self::fail('Eine ungueltige Absenderadresse darf nicht gespeichert werden.');
|
||||
} catch (InvalidForm $e) {
|
||||
self::assertStringContainsString('Absenderadresse', $e->getMessage());
|
||||
}
|
||||
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => ' '], 1);
|
||||
self::assertSame('', $forms->findBySlug('kontakt')['settings']['sender']);
|
||||
}
|
||||
|
||||
public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void
|
||||
{
|
||||
$this->contactForm();
|
||||
@@ -484,7 +598,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
|
||||
$this->pageWithForm();
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/');
|
||||
self::assertResponseIsSuccessful();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
@@ -515,9 +629,9 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
|
||||
}
|
||||
|
||||
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
||||
public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
@@ -762,7 +876,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
// As long as they are missing, the settings screen says so instead of hiding it.
|
||||
self::assertCount(1, $crawler->filter('form[action="/admin/settings/rechtliches"]'));
|
||||
|
||||
@@ -200,6 +200,39 @@ final class InstallationTest extends TestCase
|
||||
self::assertFalse($this->installation->verify('website', $issued, 'falsch'));
|
||||
}
|
||||
|
||||
public function testAnUnreachableDatabaseDoesNotReopenTheInstaller(): void
|
||||
{
|
||||
// Ein gedrehtes Passwort oder eine Wartung beim Hoster: die Datenbank antwortet nicht.
|
||||
// Frueher hiess das "nicht installiert" - und der Installer nahm von jedem Besucher eine
|
||||
// neue Datenbank samt Administratorkonto an.
|
||||
$unreachable = ConnectionFactory::create('mysql://niemand:nichts@127.0.0.1:1/weg');
|
||||
$settings = new SiteSettings($unreachable, new MediaLibrary($unreachable, $this->root.'/var/media'));
|
||||
$broken = new Installation(
|
||||
$unreachable,
|
||||
new PageRepository($unreachable, new DocumentValidator(), $settings),
|
||||
new PartialRepository($unreachable, new DocumentValidator()),
|
||||
new MenuRepository($unreachable, $settings),
|
||||
$settings,
|
||||
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
|
||||
$this->root,
|
||||
$this->root.'/var/media',
|
||||
'test-secret',
|
||||
);
|
||||
// Vor dem Datenbankschritt gibt es noch nichts zu schuetzen.
|
||||
self::assertFalse($broken->isInstalled());
|
||||
// Sobald eine Datenbank eingetragen ist, ist eine tote Verbindung eine Stoerung.
|
||||
$broken->writeEnvironment(['DATABASE_URL' => 'mysql://niemand:nichts@127.0.0.1:1/weg']);
|
||||
self::assertTrue($broken->isInstalled());
|
||||
// Die Marke der Website zaehlt auch ohne jede Verbindung - selbst ohne .env.
|
||||
unlink($this->root.'/.env');
|
||||
self::assertFalse($broken->isInstalled());
|
||||
file_put_contents($this->root.'/var/installed', gmdate('c')."\n");
|
||||
self::assertTrue($broken->isInstalled());
|
||||
// Eine leere SQLite-Datenbank ohne Marke bleibt dagegen der Installer-Fall.
|
||||
unlink($this->root.'/var/installed');
|
||||
self::assertFalse($this->installation->isInstalled());
|
||||
}
|
||||
|
||||
private function make(): Installation
|
||||
{
|
||||
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ProjectDirEnvProcessor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ProjectDirEnvProcessorTest extends TestCase
|
||||
{
|
||||
public function testOnlyTheProjectDirectoryPlaceholderIsReplaced(): void
|
||||
{
|
||||
$processor = new ProjectDirEnvProcessor('/srv/site');
|
||||
$read = static fn (string $name): string => match ($name) {
|
||||
'DATABASE_URL' => 'sqlite:///%kernel.project_dir%/var/cms.db',
|
||||
'MYSQL' => 'mysql://kunde:p%40ss%21wort@127.0.0.1:3306/kunde_db?charset=utf8mb4',
|
||||
'MEDIA' => '%kernel.project_dir%/var/media',
|
||||
};
|
||||
self::assertSame('sqlite:////srv/site/var/cms.db', $processor->getEnv('projectdir', 'DATABASE_URL', $read));
|
||||
// Prozentkodierte Zugangsdaten bleiben, wie sie sind - `resolve:` laese `%40ss%` als
|
||||
// Parameter und liesse den Container bei jeder Anfrage abbrechen.
|
||||
self::assertSame('mysql://kunde:p%40ss%21wort@127.0.0.1:3306/kunde_db?charset=utf8mb4', $processor->getEnv('projectdir', 'MYSQL', $read));
|
||||
self::assertSame('/srv/site/var/media', $processor->getEnv('projectdir', 'MEDIA', $read));
|
||||
self::assertSame(['projectdir' => 'string'], ProjectDirEnvProcessor::getProvidedTypes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
final class SiteOriginCommandTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-origin-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root);
|
||||
file_put_contents($this->root.'/.env', "APP_ENV=\"prod\"\nSITE_ORIGIN=\"https://alt.example\"\nAPP_SECRET=\"bleibt\"\n");
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->root.'/.env*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->root);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, ?string}> */
|
||||
public static function addresses(): iterable
|
||||
{
|
||||
yield 'mit www' => ['https://www.meine-firma.de', 'https://www.meine-firma.de'];
|
||||
yield 'ohne www' => ['https://meine-firma.de', 'https://meine-firma.de'];
|
||||
yield 'Schraegstrich und Grossschreibung werden bereinigt' => [' HTTPS://WWW.Meine-Firma.DE/ ', 'https://www.meine-firma.de'];
|
||||
yield 'ein Port bleibt' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080'];
|
||||
yield 'ohne Schema' => ['meine-firma.de', null];
|
||||
yield 'mit Pfad' => ['https://meine-firma.de/start', null];
|
||||
yield 'mit Abfrage' => ['https://meine-firma.de/?x=1', null];
|
||||
yield 'mit Zugangsdaten' => ['https://user:pass@meine-firma.de', null];
|
||||
yield 'falsches Schema' => ['ftp://meine-firma.de', null];
|
||||
}
|
||||
|
||||
#[DataProvider('addresses')]
|
||||
public function testOnlySchemeAndHostAreAccepted(string $input, ?string $expected): void
|
||||
{
|
||||
if (null === $expected) {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
}
|
||||
self::assertSame($expected, SiteOriginCommand::normalize($input));
|
||||
}
|
||||
|
||||
public function testTheCommandShowsAndWritesTheAddress(): void
|
||||
{
|
||||
$tester = new CommandTester(new SiteOriginCommand($this->root, 'https://alt.example'));
|
||||
self::assertSame(0, $tester->execute([]));
|
||||
self::assertStringContainsString('https://alt.example', $tester->getDisplay());
|
||||
self::assertSame('https://alt.example', $this->values()['SITE_ORIGIN'], 'Anzeigen schreibt nichts.');
|
||||
|
||||
self::assertSame(0, $tester->execute(['adresse' => 'https://www.neu.example/']));
|
||||
self::assertStringContainsString('ohne www leitet dorthin um', $tester->getDisplay());
|
||||
self::assertSame('https://www.neu.example', $this->values()['SITE_ORIGIN']);
|
||||
self::assertSame('bleibt', $this->values()['APP_SECRET'], 'Die uebrigen Einstellungen bleiben erhalten.');
|
||||
|
||||
self::assertSame(1, $tester->execute(['adresse' => 'neu.example']));
|
||||
self::assertStringContainsString('beginnt mit https://', $tester->getDisplay());
|
||||
self::assertSame('https://www.neu.example', $this->values()['SITE_ORIGIN'], 'Eine abgelehnte Adresse aendert nichts.');
|
||||
}
|
||||
|
||||
private function values(): array
|
||||
{
|
||||
return (new Dotenv())->parse((string) file_get_contents($this->root.'/.env'));
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,31 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringNotContainsString('übersteuert <code>.env</code>', $html);
|
||||
}
|
||||
|
||||
public function testTheScreenNamesThePublicAddressAndNoticesWhenItIsNotTheOneInUse(): void
|
||||
{
|
||||
// Die Verwaltung laeuft im Test unter localhost, eingetragen ist https://cms.test: der
|
||||
// Bildschirm nennt beides, statt so zu tun, als passe es.
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('https://cms.test', $html);
|
||||
self::assertStringContainsString('läuft gerade unter <code>localhost</code>', $html);
|
||||
self::assertStringContainsString('cms:site:origin', $html, 'Der Weg, es zu aendern, steht dabei.');
|
||||
|
||||
// Unter der eingetragenen Adresse selbst: kein Hinweis, dafuer die Richtung der Umleitung.
|
||||
$client->request('GET', 'https://cms.test/admin/login');
|
||||
$client->submit($client->getCrawler()->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => self::FIXTURE_PASSWORD]));
|
||||
$client->request('GET', 'https://cms.test/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('die Schreibweise mit www leitet hierher um', $html);
|
||||
self::assertStringNotContainsString('läuft gerade unter', $html);
|
||||
|
||||
// Und die andere Schreibweise kommt gar nicht erst bis zur Verwaltung.
|
||||
$client->request('GET', 'https://www.cms.test/admin/system');
|
||||
self::assertSame(301, $client->getResponse()->getStatusCode());
|
||||
self::assertSame('https://cms.test/admin/system', $client->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
|
||||
@@ -25,6 +25,10 @@ test('design fields follow block type without hiding existing stored values', ()
|
||||
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
|
||||
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
|
||||
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
|
||||
// Column shares belong to a row and to nothing else - not even to the column itself.
|
||||
assert.equal(relevantProperty(createBlock('row'), 'columnWidths', { group: 'layout' }), true);
|
||||
assert.equal(relevantProperty(createBlock('column'), 'columnWidths', { group: 'layout' }), false);
|
||||
assert.equal(relevantProperty(text, 'columnWidths', { group: 'layout' }), false);
|
||||
image.style = { mobile: { fontSize: '14px' } };
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
|
||||
});
|
||||
@@ -188,7 +192,7 @@ test('plugin blocks stay movable and intact while the editor refuses to fill or
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
|
||||
});
|
||||
|
||||
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
|
||||
test('the post list block carries only its filter, lifts the schema to v18 and undoes atomically', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
|
||||
const block = locate(history.document.blocks, id).block;
|
||||
@@ -196,7 +200,8 @@ test('the post list block carries only its filter, lifts the schema to v5 and un
|
||||
// Content is the filter and the count; the posts themselves are added when the page is served.
|
||||
assert.deepEqual(block.props, { count: 3, category: '', tag: '', layout: 'karten' });
|
||||
assert.equal(block.children, undefined);
|
||||
assert.equal(history.document.schemaVersion, 5);
|
||||
// v18, weil der Server die Darstellung "karten" erst ab dieser Fassung annimmt.
|
||||
assert.equal(history.document.schemaVersion, 18);
|
||||
|
||||
// Nothing can be nested inside it, and it does not swallow a following block.
|
||||
assert.equal(canContain(block, { type: 'text' }), false);
|
||||
@@ -427,7 +432,10 @@ test('a current document still accepts new blocks, and every block type can be i
|
||||
}
|
||||
|
||||
// Jeder Typ, der eine Fassung anhebt, landet tatsächlich im Dokument.
|
||||
for (const [type, needed] of [['gallery', 3], ['postlist', 5], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 15]]) {
|
||||
// Die Zahlen sind die des Servers fuer die Eigenschaften, die createBlock setzt (Beitragsliste
|
||||
// mit Darstellung: v18, Menue mit Art: v16, Slider mit Punkten: v17) - nicht die erste Fassung,
|
||||
// die den Typ kannte.
|
||||
for (const [type, needed] of [['gallery', 3], ['postlist', 18], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 16], ['partialref', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 17], ['social', 23]]) {
|
||||
const history = new EditorHistory(fixture());
|
||||
const id = history.change(doc => insertBlock(doc, type, 's2'));
|
||||
assert.ok(locate(history.document.blocks, id), `${type} wird eingefügt`);
|
||||
@@ -504,7 +512,7 @@ test('the top level takes everything the validator takes there, and nothing else
|
||||
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
|
||||
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
|
||||
|
||||
test('a row takes columns and nothing else, a column only lives in a row', () => {
|
||||
test('a row takes columns and nothing else, a column lives in a row or alone in a section', () => {
|
||||
const document = fixture();
|
||||
const rowId = insertRow(document, 3, 's2');
|
||||
const row = locate(document.blocks, rowId).block;
|
||||
@@ -514,8 +522,10 @@ test('a row takes columns and nothing else, a column only lives in a row', () =>
|
||||
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
|
||||
assert.equal(row.children.length, 3);
|
||||
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
|
||||
// Eine Spalte gehoert nicht in eine Sektion.
|
||||
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
|
||||
// Eine einzelne Spalte darf direkt in einer Sektion stehen - so wird ein Button zentriert -,
|
||||
// aber nicht in einer Spalte oder lose im Dokument.
|
||||
assert.ok(insertBlockAt(document, 'column', 's2', 0));
|
||||
assert.equal(insertBlockAt(document, 'column', row.children[0].id, 0), null);
|
||||
assert.ok(insertBlockAt(document, 'column', rowId, 3));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user