Einwilligung, die tatsächlich zurückhält, und Pflichtseiten
Ein Hinweis, der nur fragt und sonst nichts ändert, ist Theater. Deshalb zwei Hälften, und die zweite ist die wichtigere. Die Frage: serverseitig gerendert, per Formular beantwortet, ohne JavaScript - die öffentlichen Seiten laufen mit script-src 'none', und ein Banner mit Skript wäre das eine, was diese Regel bricht. "Alle akzeptieren" und "Nur notwendige" sind dieselbe Art Knopf, gleich groß, nebeneinander. Die Entscheidung steht in einem eigenen Cookie: Fassung der Frage, gewährte Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr. Kein CSRF-Token, absichtlich: die Anfrage ändert nichts auf dem Server, und ein Token bräuchte für jeden Besucher eine Sitzung - genau das, was ein Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das Rücksprungziel. Das Zurückhalten: der neue Block "Externer Inhalt" (Dokumentschema v14) lädt ohne Einwilligung nichts - kein iframe, keine Adresse im Markup, frame-src 'none'. Der Platzhalter nennt den Anbieter; sein Knopf gewährt genau diese Gruppe und nimmt die vorher gewährten mit. Erst danach kommt der Host in die Sicherheitsrichtlinie, und nur dieser. Im Editor bleibt es immer ein Platzhalter. Datenschutz und Impressum sind keine Einstellung mehr: fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst an, mit NOCH AUSZUFÜLLEN an jeder Pflichtangabe. Den Text schreibt das CMS nicht - das ist eine rechtliche Aussage über eine Firma. Verlinkt werden beide aus dem Template, nicht aus dem Fußbereich: ein Pflichtlink, den man löschen kann, ist keiner. Dazu der Fehler, den Umut im Screenshot gesehen hat: die Wortmarke stand als Buchstabenkolonne in einem 60 Pixel breiten Kasten. .site-header war noch ein Flexcontainer aus der Zeit des handgeschriebenen Markups und quetschte die Sektion zusammen, und design.css band jede Designklasse an #main - wo Kopf- und Fußbereich seit ADR 026 nicht mehr liegen. Beides behoben. Neu im Ausbauplan: Statistik (serverseitige, wiedererkennungsfreie Zählung, die deshalb keine Einwilligung braucht), Inhalte und Ladeverhalten prüfen (Blättern überall, minimal laden, Ladebudget), Installer (Schritt für Schritt im Browser, Migration inklusive). 245 PHP-Tests / 976 Assertions unter PHP 8.2.12 und 8.4.22. Der Browsertest fand zwei Fehler von mir: grauer Text auf dunklem Fußbereich mit 2,24:1, und das fixierte Banner verdeckte den Einwilligungsknopf am Seitenende. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84d471acd9
commit
ee446f1ea1
30 files changed
+1298
-154
No files matched your search
@@ -1,3 +1,7 @@
|
||||
twig:
|
||||
default_path: '%kernel.project_dir%/templates'
|
||||
strict_variables: true
|
||||
globals:
|
||||
# Reading the decision needs the current request, so the templates ask the gate directly
|
||||
# instead of every controller passing the same three values along.
|
||||
consentGate: '@App\Content\Consent'
|
||||
@@ -395,3 +395,27 @@ Jetzt rendert `/admin/media` die Bilder selbst, 24 je Seite, neueste zuerst, mit
|
||||
Hochgeladen wird über eine Ablagefläche, weil Umut das Auswahlfeld des Browsers zu Recht hässlich fand. Darunter liegt weiter ein echtes `input[type=file]`, nur unsichtbar und trotzdem fokussierbar; die Fläche ist dessen `label`, und der Fokusring wird über `:focus-within` auf die Fläche gezeichnet. Ohne JavaScript bleibt genau das übrig: ein Dateifeld mit Beschriftung. Das Skript ergänzt Ziehen, benennt die gewählten Dateien und überspringt alles, was kein JPEG, PNG oder WebP ist – abgeschickt wird nie von selbst, der Knopf bleibt die Stelle, an der etwas passiert. Ein `required` hat das Feld nicht: der Browser kann seine Meldung nicht an etwas Unsichtbares hängen, und der Server weist einen leeren Upload ohnehin mit einem lesbaren Satz ab.
|
||||
|
||||
Der Dialog bleibt, was er ist: der Weg, beim Bearbeiten ein Bild auszuwählen. Zwei Wege zum gleichen Bestand mit verschiedenen Aufgaben – `MediaLibrary::list()` bleibt für den Dialog neben `page()` bestehen, weil der Dialog seinen eigenen Filter mitbringt.
|
||||
|
||||
## ADR 032 – Einwilligung: das Banner ist der kleinere Teil (Dokumentschema v14, 12.09.2026)
|
||||
|
||||
Ein Einwilligungshinweis, der nur fragt und sonst nichts ändert, ist Theater: die Inhalte laden trotzdem, und die Frage war Dekoration. Deshalb besteht diese Umsetzung aus zwei Hälften, und die wichtigere ist die zweite.
|
||||
|
||||
**Die Frage.** Serverseitig gerendert, beantwortet durch ein Formular. Kein JavaScript – die öffentlichen Seiten laufen mit `script-src 'none'`, und ein Banner, das ein Skript braucht, wäre das eine, was diese Regel bricht. „Alle akzeptieren“ und „Nur notwendige“ sind dieselbe Art Knopf, nebeneinander, gleich groß; die Auswahl je Gruppe liegt in einem Aufklappfeld. Die Entscheidung steht in **einem** eigenen Cookie: Fassung der Frage, gewährte Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr, dann wird erneut gefragt. Nicht signiert, und zwar absichtlich: das Cookie beschreibt, was dieser Browser gewählt hat, ein gefälschter Wert ändert also nur, was dieser Browser sieht.
|
||||
|
||||
**Das Zurückhalten.** Ein neuer Block `embed` (Dokumentschema v14) hält Inhalte anderer Anbieter. Ohne Einwilligung entsteht **keine Anfrage**: kein iframe, keine Adresse im Markup, kein `frame-src` in der Richtlinie. An seiner Stelle steht ein Platzhalter, der den Anbieter nennt und einen Knopf trägt, der genau diese Gruppe gewährt – und die vorher gewährten mitnimmt, statt sie zu überschreiben. Erst nach der Einwilligung wird der Host der Sicherheitsrichtlinie hinzugefügt, und auch dann nur der Host dieses einen Anbieters. Im Editor bleibt es immer ein Platzhalter: die Vorschau soll nichts von außen laden.
|
||||
|
||||
Der Server prüft die Adresse beim Speichern, nicht beim Ausliefern: nur HTTPS, ein einfacher Hostname, kein Port, keine Zugangsdaten. Der Grund ist, dass diese Adresse in eine Sicherheitsrichtlinie eingesetzt wird – ein Leerzeichen darin wäre eine zweite erlaubte Quelle.
|
||||
|
||||
**Kein CSRF-Token am Einwilligungsformular.** Die Anfrage ändert nichts auf dem Server; sie setzt ein Cookie, das nur den absendenden Browser betrifft. Ein Token bräuchte eine Sitzung, und für jeden Besucher eine Sitzung zu beginnen ist genau das, was ein Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das Rücksprungziel, damit aus dem Knopf keine Weiterleitung auf fremde Adressen wird.
|
||||
|
||||
**Ohne Grund keine Frage.** Ist der Hinweis abgeschaltet, gilt jede Gruppe als gewährt und die Inhalte laden. Sonst würde das Abschalten des Banners stillschweigend jede Seite leeren, die etwas einbettet.
|
||||
|
||||
## ADR 033 – Datenschutz und Impressum sind keine Einstellung (12.09.2026)
|
||||
|
||||
Umut: „datenschutz und Impressum einbauen sind keine option“. Richtig – in Deutschland sind beide Pflicht und müssen von jeder Seite aus unmittelbar erreichbar sein. Also:
|
||||
|
||||
- Fehlt eine der beiden, **warnt die Verwaltung** auf dem Einstellungsbildschirm, und ein Knopf legt die fehlenden Seiten an und verknüpft sie. Vorhandene Seiten werden nie überschrieben; sie tragen womöglich längst den echten Text.
|
||||
- Die angelegten Seiten sind ein **Gerüst mit markierten Lücken**: jede Pflichtangabe steht als Abschnitt da, mit `NOCH AUSZUFÜLLEN` an der Stelle, wo eine Angabe über ein reales Unternehmen hingehört. Das CMS kann die Struktur vorbereiten, nicht die Erklärung schreiben – das ist eine rechtliche Aussage über eine Firma, und ein erfundener Text wäre schlimmer als eine leere Seite.
|
||||
- Beide werden **von jeder Seite verlinkt**, aus dem Template und nicht aus dem Fußbereich. Der Fußbereich ist ein frei bearbeitbares Blockdokument; ein Pflichtlink, den man versehentlich löschen kann, ist kein Pflichtlink. Die Links liegen mit dem Link „Einwilligung ändern“ in einem benannten Landmark – loser Text außerhalb aller Landmarks ist auch das, was axe zu Recht beanstandet.
|
||||
|
||||
Eine Pfadangabe wird beim Speichern geprüft: zeigt sie auf eine Seite, die es nicht gibt, wird sie abgewiesen.
|
||||
@@ -157,6 +157,49 @@ Anbieter und Tarif festlegen. MySQL oder MariaDB tatsächlich nachweisen statt n
|
||||
|
||||
Abnahme: Die Anwendung läuft auf dem Zielhosting mit MySQL. Messungen mit realen Inhalten liegen vor, einschließlich der Bedingungen.
|
||||
|
||||
### L – Statistik (Nutzerauftrag vom 12.09.2026)
|
||||
|
||||
*Braucht: nichts.*
|
||||
|
||||
Umut: „Als todo noch statistik kannst du das einbauen. Wieviel wann wo wie die seite benutzt haben?“ Ja – und zwar so, dass sie **keine Einwilligung braucht**, denn eine Messung, die niemanden wiedererkennt, ist keine Verarbeitung, über die zu fragen wäre. Das ist der Unterschied zu Google Analytics und der Grund, warum das Banner davon unberührt bleibt.
|
||||
|
||||
**Erhoben wird serverseitig, beim Ausliefern der Seite:** Tag und Stunde (UTC), Pfad, Antwortstatus, ob es ein Erstaufruf oder eine Folgeseite war (aus dem Referrer derselben Website), Herkunftsdomain bei externem Referrer, Gerätegröße-Klasse aus keinem Cookie sondern aus nichts – das geht nicht, also entfällt „wo auf der Seite geklickt wurde“ ersatzlos, solange kein Skript läuft.
|
||||
|
||||
**Gespeichert wird kein Datensatz je Aufruf, sondern ein Zähler:** eine Tabelle `stat_hit` mit `(day, hour, path, status, referrer_host)` und `count`, hochgezählt per `INSERT ... ON CONFLICT DO UPDATE`. Keine IP-Adresse, kein Kennzeichen, kein Cookie, keine Sitzung – auch nicht gehasht, denn ein Hash einer IP-Adresse bleibt ein Personenbezug. Dadurch wächst die Tabelle mit der Zahl der Seiten, nicht mit der Zahl der Besuche.
|
||||
|
||||
**Was das beantwortet:** wie viele Aufrufe wann, welche Seiten, woher verlinkt, welche Adressen 404 liefern, wie sich das über Wochen entwickelt.
|
||||
**Was das nicht beantwortet, und zwar nie:** Besucher gegen Aufrufe (ohne Wiedererkennung nicht unterscheidbar), Verweildauer, Absprungrate, Klickpfade einzelner Personen, Land oder Stadt. Wer das braucht, braucht Wiedererkennung, und damit eine Einwilligung – dann wäre es ein eigener Baustein in der Gruppe `statistics`, die dafür schon existiert.
|
||||
|
||||
**Umsetzung:** ein Abonnent, der nach der Antwort zählt (nur `text/html`, nur Status 200/404, Verwaltung und Assets ausgenommen, Bots per User-Agent-Muster verworfen), ein Bildschirm „Statistik“ mit Tages- und Wochensicht als serverseitig gezeichnetes SVG-Balkendiagramm ohne JavaScript, Aufbewahrung 14 Monate mit Verdichtung auf Tageswerte nach 60 Tagen, und ein Export als CSV. Abnahme: ein Aufruf erhöht genau einen Zähler; ein Aufruf der Verwaltung erhöht keinen; die Tabelle enthält nach 1000 Aufrufen keine Spalte, aus der eine Person hervorgeht.
|
||||
|
||||
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026)
|
||||
|
||||
*Braucht: nichts.*
|
||||
|
||||
Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchgehende Regel, nicht als einmalige Aufräumaktion:
|
||||
|
||||
- **Jede Liste blättert.** Beitragsarchiv (zehn je Seite) und Mediathek (24 je Seite) tun es. Zu prüfen und zu ändern: Seitenübersicht, Fassungsliste (feste Obergrenze 30 statt Blättern), Formulareinsendungen (feste Obergrenze 50), Benutzerliste, Weiterleitungen, Menüeinträge. Eine feste Obergrenze ist kein Blättern: sie verschweigt, dass etwas fehlt.
|
||||
- **Bilder minimal.** `loading="lazy"` und `width`/`height` überall, `srcset` mit den vorhandenen Breiten statt einer Größe für alle, und kein Bild über der Falz mit `lazy` (das verzögert das Wichtigste).
|
||||
- **Ein Ladebudget je Seite**, in der Browserprüfung nachgemessen statt geschätzt: HTML, CSS und Bilder je Seite mit einer Obergrenze, die bricht, wenn sie überschritten wird. Heute lädt eine öffentliche Seite kein JavaScript, zwei bis drei Stylesheets und die Bilder – das soll so bleiben und nachweisbar sein.
|
||||
- **Abfragen je Seite zählen.** Eine Seite mit zehn verknüpften Sektionen darf nicht zehn Einzelabfragen auslösen. Messen, dann zusammenfassen.
|
||||
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft.
|
||||
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
||||
|
||||
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
|
||||
|
||||
**Ablauf, wie bei WordPress, aber ohne dessen Nachlässigkeiten:** eine Seite unter `/install`, die nur antwortet, solange keine Installation existiert, und danach dauerhaft 404 liefert. Schritte: (1) Systemprüfung – PHP-Version, benötigte Erweiterungen, Schreibrechte auf `var/` und den Medienordner, ob `public/` wirklich der Dokumentenstamm ist; jede Prüfung mit dem Satz, was zu tun ist, wenn sie fehlschlägt. (2) Datenbank – SQLite-Datei oder MySQL-Zugangsdaten, Verbindung sofort geprüft statt erst beim Speichern. (3) Migration ausführen, mit Ausgabe der angewandten Versionen. (4) Erstes Administratorkonto – Name und Passwort, dieselben Regeln wie in der Benutzerverwaltung. (5) Website – Name, Adresse, Sprache; dazu Kopf- und Fußbereich, Startseite sowie Datenschutz- und Impressumsgerüst anlegen. (6) Fertig: Anmeldung, und eine Liste dessen, was noch auszufüllen ist.
|
||||
|
||||
**Die drei Punkte, an denen WordPress-Installer regelmäßig zum Problem werden, und was hier anders läuft:**
|
||||
- **Der Installer schreibt `.env`,** braucht dafür Schreibrechte im Projektstamm und sagt es, statt die Datei stillschweigend nicht zu schreiben. `APP_SECRET` wird erzeugt, nicht abgefragt.
|
||||
- **Er ist nach dem ersten Durchlauf tot.** Gesperrt wird über den Zustand der Datenbank (existiert ein Konto?), nicht über eine Datei, die jemand löschen kann.
|
||||
- **Er legt keine Zugangsdaten in ein Protokoll und zeigt kein Passwort erneut an.**
|
||||
|
||||
Abnahme: eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation.
|
||||
|
||||
### K – Migration von kant-it.de
|
||||
|
||||
*Braucht: B, C, D, E, F, H, J und Backendzugang.*
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Einwilligung, Pflichtseiten und ein Layoutfehler im Kopfbereich, 12.09.2026
|
||||
|
||||
- **245 PHP-Tests / 976 Assertions** unter PHP 8.2.12 und PHP 8.4.22, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- Sieben Cookiezustände und zehn abgewiesene Einbettungsadressen sind abgedeckt. Der Kern der Prüfung ist nicht das Banner, sondern die Aussage „ohne Einwilligung entsteht keine Anfrage“: geprüft wird, dass weder ein iframe noch die Adresse des Anbieters im Markup steht und dass `frame-src 'none'` bleibt.
|
||||
- Im Browsertest wird der Hinweis über den echten Einstellungsbildschirm eingeschaltet, dann aus einem zweiten Browserkontext ohne Verwaltungscookies geprüft: Banner da, kein iframe, Einwilligen über den Knopf des Platzhalters lädt den Inhalt, „Nur notwendige“ auf der Einwilligungsseite hält ihn wieder zurück. axe ohne Verstöße auf Banner und Einwilligungsseite.
|
||||
- **Der Browsertest hat zwei echte Fehler gefunden, die ich selbst eingebaut hatte.** Erstens: meine Regel für die Textfarbe im Fußbereich schlug `.tone-ink` und setzte grauen Text auf dunklen Grund – 2,24:1. Farbe gehört in die Tonregeln, nicht in eine Chromregel. Zweitens: das fixierte Banner verdeckte den Einwilligungsknopf des Platzhalters am Seitenende, sodass Playwright ihn nicht anklicken konnte – jetzt endet die Seite oberhalb des Banners, solange die Frage offen ist. Beides wäre einem Menschen erst beim Benutzen aufgefallen.
|
||||
- **Umut hat einen Fehler gefunden, den keine Prüfung gemeldet hatte:** im Kopfbereich stand die Wortmarke als Buchstabenkolonne in einem schmalen Kasten. `.site-header` war noch ein Flexcontainer aus der Zeit des handgeschriebenen Markups, und `design.css` band jede Designklasse an `#main`, wo Kopf- und Fußbereich nicht liegen. Kein Test hat es gesehen, weil kein Test das Aussehen des Kopfbereichs prüft – axe beanstandet keine 60 Pixel breite Spalte. Behoben; die Prüfung dafür bleibt das Hinsehen.
|
||||
- **Nicht geprüft:** kein Screenreader. Ob eine echte Einbettung (Karte, Video, Bewertungen) nach der Einwilligung tatsächlich lädt, ist mit einer erfundenen Adresse geprüft, nicht mit einem echten Anbieter – dafür braucht es die Pilotwebsite.
|
||||
|
||||
## Ergänzung: Benutzerverwaltung und Mediathek, 12.09.2026
|
||||
|
||||
- **217 PHP-Tests / 845 Assertions** unter PHP 8.2.12 und PHP 8.4.22, davon 16 neue für die Benutzerverwaltung; Playwright-Editortest und öffentliche Browserprüfung grün.
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
|
||||
- **Datenschutz und Impressum sind Pflicht, nicht Einstellung** (ADR 033): fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst mit `NOCH AUSZUFÜLLEN`-Markierungen an. Verlinkt werden sie aus dem Template, nicht aus dem Fußbereich, damit der Link nicht versehentlich gelöscht werden kann.
|
||||
- **Kopf- und Fußbereich lagen falsch im Layout.** Umut hat es im Screenshot gesehen: die Wortmarke stand als Buchstabenkolonne in einem 60 Pixel breiten Kasten. Zwei Ursachen, beide behoben – `.site-header` war noch ein Flexcontainer für das alte, handgeschriebene Markup und quetschte die Sektion darin zusammen, und `design.css` band alle Designklassen an `#main`, sodass Kopf- und Fußbereich außerhalb davon jede Designeinstellung ignorierten.
|
||||
- **Benutzerverwaltung** (ADR 030): Konten anlegen, Rolle ändern, Passwort setzen, Konto löschen – nur für Administratoren. Eine Rolle je Konto (Administrator, Veröffentlicher, Redakteur). **Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab**, weil die Sitzung Hash und Rollen gegen die Datenbank prüft; nur die Sitzung, die ihr eigenes Passwort ändert, bleibt angemeldet. Niemand kann sein eigenes Konto löschen oder herabstufen, deshalb bleibt immer ein Administrator übrig. Das eigene Passwort darf jeder ändern, aber nur mit dem aktuellen. Die Liste zeigt letzte Anmeldung, Passwortdatum und Anlagedatum.
|
||||
- **Mediathek als Seite** (ADR 031): `/admin/media` zeigt die Bilder direkt, 24 je Seite, mit Blättern und Archivfilter. Hochladen und Bildangaben über gewöhnliche Formulare, ohne JavaScript. Hochgeladen wird über eine **Ablagefläche**: Bilder hineinziehen oder klicken; darunter steht, welche Dateien gewählt sind und welche übersprungen wurden. Darunter liegt ein echtes, per Tastatur erreichbares Dateifeld – ohne JavaScript bleibt es der Dateidialog, und abgeschickt wird immer erst auf Knopfdruck. Der Dialog bleibt für die Bildauswahl im Editor.
|
||||
- **Verwaltung neu gestaltet** (ADR 029): ein Rahmen für alle Bildschirme mit fester Seitenleiste, aktivem Eintrag über `aria-current`, Titel, einem Satz Erklärung und Platz für die Hauptaktion. Vorher trug jede Seite ihre eigene Kopfzeile und eine eigene, unvollständige Linkliste. Je Seite eine Zeile mit dem Alltäglichen und **einem** Aufklappfeld „Einstellungen“, das an seinem Platz öffnet, statt sechs Kästen, die über den Rand hinausragten. Tabellen scrollen seitlich, statt die Karte aus dem Fenster zu schieben. Die Editorregeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden.
|
||||
@@ -68,6 +71,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
|
||||
- Benutzerverwaltung geprüft: 16 Tests, darunter zwei gleichzeitige Sitzungen, die zeigen, dass ein zurückgesetztes Passwort und eine geänderte Rolle die andere Sitzung abmelden, während die handelnde angemeldet bleibt. Acht abgewiesene Kontoanlagen (zu kurzes Passwort, mehr als 72 Bytes, Nullbyte, drei ungültige Namen, Doppelname, unbekannte Rolle) legen kein Konto an. Jeder Schreibweg braucht ein CSRF-Token und ist ohne Administratorrolle 403.
|
||||
- Mediathek geprüft: Upload über das Formular mit zwei Dateien in einem Absenden, CSRF-Pflicht, Bildangaben mit Versionskonflikt, Archivfilter, Blättern bei 26 Bildern (24 auf Seite eins, korrekte Sortierung neueste zuerst, `?seite=99` wird auf die letzte Seite begrenzt) und Rückkehr auf dieselbe Seite nach dem Speichern. Die Ablagefläche ist im Browsertest geprüft (gewählte Dateien erscheinen im echten Dateifeld und werden benannt) und von Hand mit einem echten Drop, bei dem eine Nicht-Bilddatei übersprungen wurde.
|
||||
|
||||
@@ -546,3 +546,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.login-card { padding: 26px }
|
||||
.login-body { padding: 16px }
|
||||
}
|
||||
|
||||
/* A group of related checkboxes needs a name of its own, not just a label per box. */
|
||||
.field-set { border: 1px solid var(--line); border-radius: 10px; padding: 4px 16px 16px; margin: 18px 0 0 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
+132
-132
@@ -1,137 +1,137 @@
|
||||
/* Generated from design-tokens.json by npm run build:design. */
|
||||
#main .d-desktop-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-desktop-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-desktop-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-desktop-fontSize-small{font-size:14px}
|
||||
#main .d-desktop-fontSize-body{font-size:18px}
|
||||
#main .d-desktop-fontSize-large{font-size:24px}
|
||||
#main .d-desktop-fontSize-title{font-size:36px}
|
||||
#main .d-desktop-fontSize-display{font-size:52px}
|
||||
#main .d-desktop-fontSize-hero{font-size:72px}
|
||||
#main .d-desktop-fontWeight-regular{font-weight:400}
|
||||
#main .d-desktop-fontWeight-medium{font-weight:500}
|
||||
#main .d-desktop-fontWeight-bold{font-weight:700}
|
||||
#main .d-desktop-lineHeight-tight{line-height:1.15}
|
||||
#main .d-desktop-lineHeight-normal{line-height:1.5}
|
||||
#main .d-desktop-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-desktop-paddingY-none{padding-block:0}
|
||||
#main .d-desktop-paddingY-small{padding-block:16px}
|
||||
#main .d-desktop-paddingY-medium{padding-block:32px}
|
||||
#main .d-desktop-paddingY-large{padding-block:64px}
|
||||
#main .d-desktop-paddingY-huge{padding-block:96px}
|
||||
#main .d-desktop-paddingX-none{padding-inline:0}
|
||||
#main .d-desktop-paddingX-small{padding-inline:16px}
|
||||
#main .d-desktop-paddingX-medium{padding-inline:32px}
|
||||
#main .d-desktop-paddingX-large{padding-inline:64px}
|
||||
#main .d-desktop-marginTop-none{margin-top:0}
|
||||
#main .d-desktop-marginTop-small{margin-top:8px}
|
||||
#main .d-desktop-marginTop-medium{margin-top:24px}
|
||||
#main .d-desktop-marginTop-large{margin-top:48px}
|
||||
#main .d-desktop-marginBottom-none{margin-bottom:0}
|
||||
#main .d-desktop-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-desktop-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-desktop-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-desktop-gap-none{gap:0}
|
||||
#main .d-desktop-gap-small{gap:12px}
|
||||
#main .d-desktop-gap-medium{gap:24px}
|
||||
#main .d-desktop-gap-large{gap:48px}
|
||||
#main .d-desktop-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-desktop-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-desktop-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-desktop-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-desktop-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-desktop-radius-none{border-radius:0}
|
||||
#main .d-desktop-radius-small{border-radius:8px}
|
||||
#main .d-desktop-radius-large{border-radius:24px}
|
||||
.kant .d-desktop-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-desktop-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-desktop-align-right{text-align:right;margin-inline:auto 0}
|
||||
.kant .d-desktop-fontSize-small{font-size:14px}
|
||||
.kant .d-desktop-fontSize-body{font-size:18px}
|
||||
.kant .d-desktop-fontSize-large{font-size:24px}
|
||||
.kant .d-desktop-fontSize-title{font-size:36px}
|
||||
.kant .d-desktop-fontSize-display{font-size:52px}
|
||||
.kant .d-desktop-fontSize-hero{font-size:72px}
|
||||
.kant .d-desktop-fontWeight-regular{font-weight:400}
|
||||
.kant .d-desktop-fontWeight-medium{font-weight:500}
|
||||
.kant .d-desktop-fontWeight-bold{font-weight:700}
|
||||
.kant .d-desktop-lineHeight-tight{line-height:1.15}
|
||||
.kant .d-desktop-lineHeight-normal{line-height:1.5}
|
||||
.kant .d-desktop-lineHeight-relaxed{line-height:1.8}
|
||||
.kant .d-desktop-paddingY-none{padding-block:0}
|
||||
.kant .d-desktop-paddingY-small{padding-block:16px}
|
||||
.kant .d-desktop-paddingY-medium{padding-block:32px}
|
||||
.kant .d-desktop-paddingY-large{padding-block:64px}
|
||||
.kant .d-desktop-paddingY-huge{padding-block:96px}
|
||||
.kant .d-desktop-paddingX-none{padding-inline:0}
|
||||
.kant .d-desktop-paddingX-small{padding-inline:16px}
|
||||
.kant .d-desktop-paddingX-medium{padding-inline:32px}
|
||||
.kant .d-desktop-paddingX-large{padding-inline:64px}
|
||||
.kant .d-desktop-marginTop-none{margin-top:0}
|
||||
.kant .d-desktop-marginTop-small{margin-top:8px}
|
||||
.kant .d-desktop-marginTop-medium{margin-top:24px}
|
||||
.kant .d-desktop-marginTop-large{margin-top:48px}
|
||||
.kant .d-desktop-marginBottom-none{margin-bottom:0}
|
||||
.kant .d-desktop-marginBottom-small{margin-bottom:8px}
|
||||
.kant .d-desktop-marginBottom-medium{margin-bottom:24px}
|
||||
.kant .d-desktop-marginBottom-large{margin-bottom:48px}
|
||||
.kant .d-desktop-gap-none{gap:0}
|
||||
.kant .d-desktop-gap-small{gap:12px}
|
||||
.kant .d-desktop-gap-medium{gap:24px}
|
||||
.kant .d-desktop-gap-large{gap:48px}
|
||||
.kant .d-desktop-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
.kant .d-desktop-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
.kant .d-desktop-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.kant .d-desktop-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.kant .d-desktop-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.kant .d-desktop-radius-none{border-radius:0}
|
||||
.kant .d-desktop-radius-small{border-radius:8px}
|
||||
.kant .d-desktop-radius-large{border-radius:24px}
|
||||
@media(max-width:1024px){
|
||||
#main .d-tablet-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-tablet-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-tablet-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-tablet-fontSize-small{font-size:14px}
|
||||
#main .d-tablet-fontSize-body{font-size:18px}
|
||||
#main .d-tablet-fontSize-large{font-size:24px}
|
||||
#main .d-tablet-fontSize-title{font-size:36px}
|
||||
#main .d-tablet-fontSize-display{font-size:52px}
|
||||
#main .d-tablet-fontSize-hero{font-size:72px}
|
||||
#main .d-tablet-fontWeight-regular{font-weight:400}
|
||||
#main .d-tablet-fontWeight-medium{font-weight:500}
|
||||
#main .d-tablet-fontWeight-bold{font-weight:700}
|
||||
#main .d-tablet-lineHeight-tight{line-height:1.15}
|
||||
#main .d-tablet-lineHeight-normal{line-height:1.5}
|
||||
#main .d-tablet-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-tablet-paddingY-none{padding-block:0}
|
||||
#main .d-tablet-paddingY-small{padding-block:16px}
|
||||
#main .d-tablet-paddingY-medium{padding-block:32px}
|
||||
#main .d-tablet-paddingY-large{padding-block:64px}
|
||||
#main .d-tablet-paddingY-huge{padding-block:96px}
|
||||
#main .d-tablet-paddingX-none{padding-inline:0}
|
||||
#main .d-tablet-paddingX-small{padding-inline:16px}
|
||||
#main .d-tablet-paddingX-medium{padding-inline:32px}
|
||||
#main .d-tablet-paddingX-large{padding-inline:64px}
|
||||
#main .d-tablet-marginTop-none{margin-top:0}
|
||||
#main .d-tablet-marginTop-small{margin-top:8px}
|
||||
#main .d-tablet-marginTop-medium{margin-top:24px}
|
||||
#main .d-tablet-marginTop-large{margin-top:48px}
|
||||
#main .d-tablet-marginBottom-none{margin-bottom:0}
|
||||
#main .d-tablet-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-tablet-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-tablet-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-tablet-gap-none{gap:0}
|
||||
#main .d-tablet-gap-small{gap:12px}
|
||||
#main .d-tablet-gap-medium{gap:24px}
|
||||
#main .d-tablet-gap-large{gap:48px}
|
||||
#main .d-tablet-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-tablet-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-tablet-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-tablet-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-tablet-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-tablet-radius-none{border-radius:0}
|
||||
#main .d-tablet-radius-small{border-radius:8px}
|
||||
#main .d-tablet-radius-large{border-radius:24px}
|
||||
.kant .d-tablet-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-tablet-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-tablet-align-right{text-align:right;margin-inline:auto 0}
|
||||
.kant .d-tablet-fontSize-small{font-size:14px}
|
||||
.kant .d-tablet-fontSize-body{font-size:18px}
|
||||
.kant .d-tablet-fontSize-large{font-size:24px}
|
||||
.kant .d-tablet-fontSize-title{font-size:36px}
|
||||
.kant .d-tablet-fontSize-display{font-size:52px}
|
||||
.kant .d-tablet-fontSize-hero{font-size:72px}
|
||||
.kant .d-tablet-fontWeight-regular{font-weight:400}
|
||||
.kant .d-tablet-fontWeight-medium{font-weight:500}
|
||||
.kant .d-tablet-fontWeight-bold{font-weight:700}
|
||||
.kant .d-tablet-lineHeight-tight{line-height:1.15}
|
||||
.kant .d-tablet-lineHeight-normal{line-height:1.5}
|
||||
.kant .d-tablet-lineHeight-relaxed{line-height:1.8}
|
||||
.kant .d-tablet-paddingY-none{padding-block:0}
|
||||
.kant .d-tablet-paddingY-small{padding-block:16px}
|
||||
.kant .d-tablet-paddingY-medium{padding-block:32px}
|
||||
.kant .d-tablet-paddingY-large{padding-block:64px}
|
||||
.kant .d-tablet-paddingY-huge{padding-block:96px}
|
||||
.kant .d-tablet-paddingX-none{padding-inline:0}
|
||||
.kant .d-tablet-paddingX-small{padding-inline:16px}
|
||||
.kant .d-tablet-paddingX-medium{padding-inline:32px}
|
||||
.kant .d-tablet-paddingX-large{padding-inline:64px}
|
||||
.kant .d-tablet-marginTop-none{margin-top:0}
|
||||
.kant .d-tablet-marginTop-small{margin-top:8px}
|
||||
.kant .d-tablet-marginTop-medium{margin-top:24px}
|
||||
.kant .d-tablet-marginTop-large{margin-top:48px}
|
||||
.kant .d-tablet-marginBottom-none{margin-bottom:0}
|
||||
.kant .d-tablet-marginBottom-small{margin-bottom:8px}
|
||||
.kant .d-tablet-marginBottom-medium{margin-bottom:24px}
|
||||
.kant .d-tablet-marginBottom-large{margin-bottom:48px}
|
||||
.kant .d-tablet-gap-none{gap:0}
|
||||
.kant .d-tablet-gap-small{gap:12px}
|
||||
.kant .d-tablet-gap-medium{gap:24px}
|
||||
.kant .d-tablet-gap-large{gap:48px}
|
||||
.kant .d-tablet-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
.kant .d-tablet-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
.kant .d-tablet-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.kant .d-tablet-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.kant .d-tablet-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.kant .d-tablet-radius-none{border-radius:0}
|
||||
.kant .d-tablet-radius-small{border-radius:8px}
|
||||
.kant .d-tablet-radius-large{border-radius:24px}
|
||||
}
|
||||
@media(max-width:650px){
|
||||
#main .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-mobile-fontSize-small{font-size:14px}
|
||||
#main .d-mobile-fontSize-body{font-size:18px}
|
||||
#main .d-mobile-fontSize-large{font-size:24px}
|
||||
#main .d-mobile-fontSize-title{font-size:36px}
|
||||
#main .d-mobile-fontSize-display{font-size:52px}
|
||||
#main .d-mobile-fontSize-hero{font-size:72px}
|
||||
#main .d-mobile-fontWeight-regular{font-weight:400}
|
||||
#main .d-mobile-fontWeight-medium{font-weight:500}
|
||||
#main .d-mobile-fontWeight-bold{font-weight:700}
|
||||
#main .d-mobile-lineHeight-tight{line-height:1.15}
|
||||
#main .d-mobile-lineHeight-normal{line-height:1.5}
|
||||
#main .d-mobile-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-mobile-paddingY-none{padding-block:0}
|
||||
#main .d-mobile-paddingY-small{padding-block:16px}
|
||||
#main .d-mobile-paddingY-medium{padding-block:32px}
|
||||
#main .d-mobile-paddingY-large{padding-block:64px}
|
||||
#main .d-mobile-paddingY-huge{padding-block:96px}
|
||||
#main .d-mobile-paddingX-none{padding-inline:0}
|
||||
#main .d-mobile-paddingX-small{padding-inline:16px}
|
||||
#main .d-mobile-paddingX-medium{padding-inline:32px}
|
||||
#main .d-mobile-paddingX-large{padding-inline:64px}
|
||||
#main .d-mobile-marginTop-none{margin-top:0}
|
||||
#main .d-mobile-marginTop-small{margin-top:8px}
|
||||
#main .d-mobile-marginTop-medium{margin-top:24px}
|
||||
#main .d-mobile-marginTop-large{margin-top:48px}
|
||||
#main .d-mobile-marginBottom-none{margin-bottom:0}
|
||||
#main .d-mobile-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-mobile-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-mobile-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-mobile-gap-none{gap:0}
|
||||
#main .d-mobile-gap-small{gap:12px}
|
||||
#main .d-mobile-gap-medium{gap:24px}
|
||||
#main .d-mobile-gap-large{gap:48px}
|
||||
#main .d-mobile-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-mobile-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-mobile-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-mobile-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-mobile-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-mobile-radius-none{border-radius:0}
|
||||
#main .d-mobile-radius-small{border-radius:8px}
|
||||
#main .d-mobile-radius-large{border-radius:24px}
|
||||
.kant .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
.kant .d-mobile-fontSize-small{font-size:14px}
|
||||
.kant .d-mobile-fontSize-body{font-size:18px}
|
||||
.kant .d-mobile-fontSize-large{font-size:24px}
|
||||
.kant .d-mobile-fontSize-title{font-size:36px}
|
||||
.kant .d-mobile-fontSize-display{font-size:52px}
|
||||
.kant .d-mobile-fontSize-hero{font-size:72px}
|
||||
.kant .d-mobile-fontWeight-regular{font-weight:400}
|
||||
.kant .d-mobile-fontWeight-medium{font-weight:500}
|
||||
.kant .d-mobile-fontWeight-bold{font-weight:700}
|
||||
.kant .d-mobile-lineHeight-tight{line-height:1.15}
|
||||
.kant .d-mobile-lineHeight-normal{line-height:1.5}
|
||||
.kant .d-mobile-lineHeight-relaxed{line-height:1.8}
|
||||
.kant .d-mobile-paddingY-none{padding-block:0}
|
||||
.kant .d-mobile-paddingY-small{padding-block:16px}
|
||||
.kant .d-mobile-paddingY-medium{padding-block:32px}
|
||||
.kant .d-mobile-paddingY-large{padding-block:64px}
|
||||
.kant .d-mobile-paddingY-huge{padding-block:96px}
|
||||
.kant .d-mobile-paddingX-none{padding-inline:0}
|
||||
.kant .d-mobile-paddingX-small{padding-inline:16px}
|
||||
.kant .d-mobile-paddingX-medium{padding-inline:32px}
|
||||
.kant .d-mobile-paddingX-large{padding-inline:64px}
|
||||
.kant .d-mobile-marginTop-none{margin-top:0}
|
||||
.kant .d-mobile-marginTop-small{margin-top:8px}
|
||||
.kant .d-mobile-marginTop-medium{margin-top:24px}
|
||||
.kant .d-mobile-marginTop-large{margin-top:48px}
|
||||
.kant .d-mobile-marginBottom-none{margin-bottom:0}
|
||||
.kant .d-mobile-marginBottom-small{margin-bottom:8px}
|
||||
.kant .d-mobile-marginBottom-medium{margin-bottom:24px}
|
||||
.kant .d-mobile-marginBottom-large{margin-bottom:48px}
|
||||
.kant .d-mobile-gap-none{gap:0}
|
||||
.kant .d-mobile-gap-small{gap:12px}
|
||||
.kant .d-mobile-gap-medium{gap:24px}
|
||||
.kant .d-mobile-gap-large{gap:48px}
|
||||
.kant .d-mobile-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
.kant .d-mobile-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
.kant .d-mobile-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.kant .d-mobile-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.kant .d-mobile-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.kant .d-mobile-radius-none{border-radius:0}
|
||||
.kant .d-mobile-radius-small{border-radius:8px}
|
||||
.kant .d-mobile-radius-large{border-radius:24px}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
@@ -48,6 +48,8 @@ export function createBlock(type) {
|
||||
if (type === 'form') block.props = { form: '' };
|
||||
if (type === 'menu') block.props = { slot: 'header' };
|
||||
if (type === 'partialref') block.props = { partial: '' };
|
||||
// An embed starts empty and blocked: no address, so nothing loads before someone enters one.
|
||||
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
@@ -61,6 +63,7 @@ export function insertBlock(document, type, selected) {
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
|
||||
@@ -237,6 +237,14 @@ function renderProperties() {
|
||||
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
|
||||
if (block.type === 'menu') field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', footer: 'Fußzeile' }, help: 'Die Einträge werden unter Menüs gepflegt.' });
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'embed') {
|
||||
field('Gruppe', 'group', block.props.group, { choices: { external: 'Externe Inhalte', statistics: 'Statistik' }, help: 'Bestimmt, welche Einwilligung dieser Inhalt braucht. Vor der Einwilligung wird nichts geladen – kein Bild, kein Skript, keine Anfrage an den Anbieter.' });
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Was hier eingebettet ist, in einem Satz. Screenreader lesen das als Namen des Rahmens vor, deshalb nicht leer lassen.' });
|
||||
field('Anbieter', 'provider', block.props.provider, { maxLength: 80, help: 'Wer den Inhalt ausliefert, zum Beispiel Google Maps. Steht im Platzhalter, damit die Entscheidung eine Grundlage hat.' });
|
||||
field('Adresse (HTTPS)', 'url', block.props.url, { help: 'Die Einbettungsadresse des Anbieters. Nur HTTPS, ohne Port. Dieser Host wird der Sicherheitsrichtlinie der Seite hinzugefügt – und zwar nur, wenn eingewilligt wurde.' });
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
|
||||
+58
-2
@@ -2,8 +2,8 @@
|
||||
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{display:flex;justify-content:space-between;align-items:center;min-height:106px;border-bottom:1px solid var(--line);gap:24px}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;padding-block:34px;border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header{min-height:100px;align-items:flex-start;flex-direction:column;justify-content:center;gap:18px;padding-block:22px}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
@@ -104,3 +104,59 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text,
|
||||
.tone-ink .block-column>.block-text:first-child{color:#dae3da}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:#fff;color:#203b34;border:1px solid #cfd8d1;border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px #1b2a2426;font-size:14px;line-height:1.6}
|
||||
.consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid #2c6b4f;background:#2c6b4f;color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid #b55b12;outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:#a4510f}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
body.consent-open{padding-bottom:260px}
|
||||
@media (min-width:900px){body.consent-open{padding-bottom:200px}}
|
||||
.consent-page main{max-width:680px;margin-inline:auto;padding:40px 20px}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;margin-top:24px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:#eef1ed}
|
||||
.embed-blocked{border:1px dashed #b3c0b7;border-radius:10px;padding:24px;background:#f6f7f4;text-align:center}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:#4a5751}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:#4a5751}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
wrapped one letter per line. Their sections are chrome, not articles, so they sit tighter than
|
||||
a section in the page body, and their rows keep their columns side by side. */
|
||||
.site-header .block-section,
|
||||
.site-footer .block-section{padding-block:20px}
|
||||
.site-header .block-row,
|
||||
.site-footer .block-row{margin-top:0;align-items:center}
|
||||
.site-header .block-column,
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* Size and weight only, never colour: the tone of the area decides that, and a colour here won
|
||||
against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */
|
||||
.site-header .block-column>.block-text:first-child{font-size:24px;font-weight:800;letter-spacing:-1px;text-transform:none;margin:0}
|
||||
.site-footer .block-column>.block-text:first-child{font-size:14px;font-weight:400;letter-spacing:0;text-transform:none;margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:#f1f3ef}
|
||||
.legal-bar a{color:#a4510f}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* The design classes are scoped to the body class, not to #main: the header and the footer are
|
||||
* block documents of their own and live outside #main, so a #main prefix left every design token
|
||||
* in them without effect - a row in the header ignored its column count.
|
||||
*/
|
||||
export function designCss(tokens) {
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `#main .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
|
||||
}
|
||||
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
|
||||
|
||||
@@ -372,6 +372,63 @@ try {
|
||||
await page.screenshot({ path: 'reports/editor/media-screen.png', fullPage: true });
|
||||
checks.push({ name: 'management screens reachable, media library rendered as a page', passed: true });
|
||||
|
||||
// The consent question, switched on the way an administrator would switch it on, and then
|
||||
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
|
||||
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
// A page with an embed, added through the API so the check stays about consent, not the editor.
|
||||
const forEmbed = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
|
||||
const withEmbed = structuredClone(forEmbed.document);
|
||||
withEmbed.schemaVersion = 14;
|
||||
withEmbed.blocks.push({
|
||||
id: 'karte', type: 'embed',
|
||||
props: { group: 'external', title: 'Anfahrt', provider: 'OpenStreetMap', url: 'https://karten.example/einbetten', hint: '', height: 320 },
|
||||
});
|
||||
const embedSave = await context.request.post(origin + '/admin/api/pages/test-projektstand/draft', { headers: { 'X-CSRF-Token': forEmbed.csrfToken }, data: { document: withEmbed, expectedVersion: forEmbed.version } });
|
||||
assert.equal(embedSave.status(), 200, await embedSave.text());
|
||||
// The version and the revision after a save are whatever the server says, not an assumption.
|
||||
const afterSave = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
|
||||
const published = await context.request.post(origin + '/admin/api/pages/test-projektstand/publish', { headers: { 'X-CSRF-Token': afterSave.csrfToken }, data: { revisionId: afterSave.revisionId, expectedVersion: afterSave.version } });
|
||||
assert.equal(published.status(), 200, await published.text());
|
||||
|
||||
// A separate context: a visitor without the administration's cookies.
|
||||
const visitor = await browser.newContext({ viewport: { width: 1100, height: 900 } });
|
||||
const visitorPage = await visitor.newPage();
|
||||
await visitorPage.goto(origin + '/projektstand');
|
||||
await visitorPage.locator('.consent-bar').waitFor();
|
||||
assert.equal(await visitorPage.locator('iframe').count(), 0, 'Nothing may load before consent.');
|
||||
assert.equal((await visitorPage.content()).includes('karten.example/einbetten'), false, 'The address must not even be in the markup.');
|
||||
const blocked = await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
assert.deepEqual(blocked.violations.map(v => v.id), [], 'consent banner accessibility');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-banner.png', fullPage: false });
|
||||
|
||||
// Consenting through the embed's own button grants exactly that group, and then it loads.
|
||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
||||
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
|
||||
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
|
||||
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||
await visitorPage.goto(origin + '/einwilligung');
|
||||
await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()
|
||||
.then(result => assert.deepEqual(result.violations.map(v => v.id), [], 'consent page accessibility'));
|
||||
await visitorPage.getByRole('button', { name: 'Nur notwendige', exact: true }).click();
|
||||
await visitorPage.goto(origin + '/projektstand');
|
||||
assert.equal(await visitorPage.locator('iframe').count(), 0, 'A refusal must stick.');
|
||||
await visitor.close();
|
||||
checks.push({ name: 'consent banner, blocked embed, granting per embed and revoking', passed: true });
|
||||
console.log('Consent checks passed.');
|
||||
|
||||
// Back to the way the rest of the run expects it: no question, so the other screens stay as they were.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).uncheck();
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
// Back into the editor. The draft differs from the published state by now, so wait for the
|
||||
// structure rather than for a particular save status.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The visitor's decision about groups of content that may not load without consent.
|
||||
*
|
||||
* Two things matter here, and the banner is the smaller one. The banner only asks; what makes the
|
||||
* answer worth anything is that an embed does not load until it has been given - that is why
|
||||
* `granted()` is asked in the renderer and not only in the template.
|
||||
*
|
||||
* The decision lives in one first-party cookie and nowhere else: no server record, no identifier,
|
||||
* no third party. The cookie holds the version of the question, the groups that were granted and
|
||||
* when - nothing about the person. It is deliberately not signed: it describes what this browser
|
||||
* chose, so a forged value can only change what that same browser gets shown.
|
||||
*/
|
||||
final class Consent
|
||||
{
|
||||
public const COOKIE = 'kant_consent';
|
||||
|
||||
/**
|
||||
* Necessary content is not a group: it is what remains when nothing is granted, and it is
|
||||
* never asked about. Only these two can be asked about.
|
||||
*/
|
||||
public const GROUPS = [
|
||||
'statistics' => 'Statistik',
|
||||
'external' => 'Externe Inhalte',
|
||||
];
|
||||
|
||||
public const GROUP_HELP = [
|
||||
'statistics' => 'Messung, wie die Website benutzt wird.',
|
||||
'external' => 'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Half a year, then the question is asked again. Long enough not to be a nuisance, short
|
||||
* enough that a decision is not silently eternal.
|
||||
*/
|
||||
public const MAX_AGE = 15552000;
|
||||
|
||||
/** Raise this when the groups or their meaning change: an old answer then no longer counts. */
|
||||
public const VERSION = 1;
|
||||
|
||||
public function __construct(private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
/** Which groups this site asks about at all. Empty means: nothing to consent to. */
|
||||
public function askedGroups(): array
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings['consentBanner']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_intersect(array_keys(self::GROUPS), $settings['consentGroups']));
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored decision, or null when there is none that still counts.
|
||||
*
|
||||
* @return array{granted: list<string>, at: int}|null
|
||||
*/
|
||||
public function decision(Request $request): ?array
|
||||
{
|
||||
$raw = $request->cookies->get(self::COOKIE);
|
||||
if (!is_string($raw) || strlen($raw) > 200) {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('|', $raw);
|
||||
if (3 !== count($parts) || (string) self::VERSION !== $parts[0]) {
|
||||
return null;
|
||||
}
|
||||
$at = filter_var($parts[2], FILTER_VALIDATE_INT);
|
||||
if (false === $at || $at <= 0 || $at + self::MAX_AGE < time()) {
|
||||
return null;
|
||||
}
|
||||
$granted = array_values(array_intersect(
|
||||
array_keys(self::GROUPS),
|
||||
'' === $parts[1] ? [] : explode(',', $parts[1]),
|
||||
));
|
||||
|
||||
return ['granted' => $granted, 'at' => $at];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a group may load.
|
||||
*
|
||||
* A group this site never asks about counts as granted: otherwise switching the banner off
|
||||
* would silently hide content that needs no consent.
|
||||
*/
|
||||
public function granted(Request $request, string $group): bool
|
||||
{
|
||||
if (!in_array($group, $this->askedGroups(), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($group, $this->decision($request)['granted'] ?? [], true);
|
||||
}
|
||||
|
||||
/** Whether this request should be shown the question. */
|
||||
public function needsBanner(Request $request): bool
|
||||
{
|
||||
return [] !== $this->askedGroups() && null === $this->decision($request);
|
||||
}
|
||||
|
||||
/** @param list<string> $groups */
|
||||
public function remember(Request $request, Response $response, array $groups): void
|
||||
{
|
||||
$granted = array_values(array_intersect($this->askedGroups(), $groups));
|
||||
$response->headers->setCookie(Cookie::create(
|
||||
self::COOKIE,
|
||||
sprintf('%d|%s|%d', self::VERSION, implode(',', $granted), time()),
|
||||
time() + self::MAX_AGE,
|
||||
'/',
|
||||
null,
|
||||
$request->isSecure(),
|
||||
// No script needs to read it: the banner is rendered by the server.
|
||||
true,
|
||||
false,
|
||||
Cookie::SAMESITE_LAX,
|
||||
));
|
||||
}
|
||||
|
||||
public function forget(Response $response): void
|
||||
{
|
||||
$response->headers->clearCookie(self::COOKIE, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The two mandatory pages, as link path => label, for the note every page carries.
|
||||
*
|
||||
* It sits in this class because the templates already have it at hand, and because the note
|
||||
* belongs next to the consent link: both are things a visitor must be able to reach from
|
||||
* anywhere, not things an editor can delete out of the footer by accident.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function legalLinks(): array
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
$links = [];
|
||||
foreach (['privacyPath' => 'Datenschutz', 'imprintPath' => 'Impressum'] as $key => $label) {
|
||||
if ('' !== $settings[$key]) {
|
||||
$links[$settings[$key]] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/** What the banner and the consent page need to render. */
|
||||
public function state(Request $request): array
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
$decision = $this->decision($request);
|
||||
|
||||
return [
|
||||
'groups' => array_map(
|
||||
static fn (string $group): array => [
|
||||
'key' => $group,
|
||||
'name' => self::GROUPS[$group],
|
||||
'help' => self::GROUP_HELP[$group],
|
||||
'granted' => in_array($group, $decision['granted'] ?? [], true),
|
||||
],
|
||||
$this->askedGroups(),
|
||||
),
|
||||
'decided' => null !== $decision,
|
||||
'decidedAt' => $decision['at'] ?? null,
|
||||
'text' => $settings['consentText'],
|
||||
'privacyPath' => $settings['privacyPath'],
|
||||
'imprintPath' => $settings['imprintPath'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,57 @@ final class DemoContent
|
||||
}
|
||||
|
||||
/** The first draft of a newly created page: valid, empty enough to replace, with exactly one H1. */
|
||||
/**
|
||||
* The two pages a German business website must have. Not demo content and not optional: they
|
||||
* are created with a skeleton whose gaps are marked, because the text is a legal statement
|
||||
* about a real company - the CMS can prepare the structure, not write the declaration.
|
||||
*/
|
||||
public const LEGAL = [
|
||||
'datenschutz' => 'Datenschutzerklärung',
|
||||
'impressum' => 'Impressum',
|
||||
];
|
||||
|
||||
public static function legalPage(string $kind): array
|
||||
{
|
||||
$sections = 'impressum' === $kind
|
||||
? [
|
||||
['Angaben gemäß § 5 DDG', "Firmenname\nStraße und Hausnummer\nPostleitzahl und Ort\n\nNOCH AUSZUFÜLLEN: Rechtsform, Vertretungsberechtigte, Registergericht und Registernummer, Umsatzsteuer-Identifikationsnummer."],
|
||||
['Kontakt', "Telefon: \nE-Mail: \n\nNOCH AUSZUFÜLLEN: eine Adresse, unter der eine Anfrage tatsächlich beantwortet wird."],
|
||||
['Verantwortlich für den Inhalt', 'NOCH AUSZUFÜLLEN: Name und Adresse der verantwortlichen Person, falls von oben abweichend. Bei redaktionellen Inhalten nach § 18 Abs. 2 MStV erforderlich.'],
|
||||
['Streitschlichtung', 'NOCH AUSZUFÜLLEN oder entfernen: Hinweis auf eine Verbraucherschlichtungsstelle, sofern eine Teilnahmepflicht besteht.'],
|
||||
]
|
||||
: [
|
||||
['Verantwortliche Stelle', "Firmenname\nStraße und Hausnummer\nPostleitzahl und Ort\nE-Mail: \n\nNOCH AUSZUFÜLLEN: gegebenenfalls Datenschutzbeauftragte oder Datenschutzbeauftragter mit Kontaktdaten."],
|
||||
['Welche Daten beim Besuch anfallen', "Beim Aufruf dieser Website verarbeitet der Server technisch notwendige Daten: abgerufene Adresse, Zeitpunkt, übertragene Datenmenge, Browser- und Betriebssystemangaben sowie die IP-Adresse.\n\nNOCH AUSZUFÜLLEN: Speicherdauer der Serverprotokolle und Name des Hosters, mit dem ein Vertrag über die Auftragsverarbeitung besteht."],
|
||||
['Cookies und Einwilligung', "Diese Website setzt von sich aus keine Cookies zur Analyse oder Werbung. Wird eine Einwilligung abgefragt, speichert ein einzelnes Cookie dieser Website ausschließlich die Entscheidung selbst – ohne Kennung, für ein halbes Jahr. Die Entscheidung lässt sich jederzeit unter /einwilligung ändern.\n\nNOCH PRÜFEN: Dieser Abschnitt muss zu den tatsächlich eingebundenen Inhalten passen."],
|
||||
['Eingebettete Inhalte anderer Anbieter', 'NOCH AUSZUFÜLLEN: Für jeden eingebetteten Inhalt Anbieter, Zweck, Rechtsgrundlage und Empfängerland angeben. Ohne Einwilligung wird ein solcher Inhalt nicht geladen und es entsteht keine Verbindung zum Anbieter.'],
|
||||
['Kontaktformular', 'NOCH AUSZUFÜLLEN: Welche Felder erhoben werden, wofür die Angaben verwendet werden, wie lange eine Anfrage gespeichert bleibt und auf welcher Rechtsgrundlage.'],
|
||||
['Deine Rechte', 'Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung kann jederzeit widerrufen werden. Außerdem besteht ein Beschwerderecht bei einer Datenschutzaufsichtsbehörde.'],
|
||||
];
|
||||
|
||||
$blocks = [
|
||||
self::section('legal-hero', [
|
||||
self::heading('legal-heading', self::LEGAL[$kind], 1),
|
||||
self::text('legal-intro', 'Diese Seite wurde von KantSites angelegt und muss noch ausgefüllt werden. Die mit „NOCH AUSZUFÜLLEN“ markierten Stellen sind Pflichtangaben; der Text ist keine Rechtsberatung.'),
|
||||
], 'mint'),
|
||||
];
|
||||
foreach ($sections as $index => [$title, $text]) {
|
||||
$blocks[] = self::section('legal-'.$index, [
|
||||
self::heading('legal-title-'.$index, $title, 2),
|
||||
self::text('legal-text-'.$index, $text),
|
||||
], 'paper', 'narrow');
|
||||
}
|
||||
|
||||
return [
|
||||
'schemaVersion' => 3,
|
||||
'title' => self::LEGAL[$kind],
|
||||
'description' => 'impressum' === $kind
|
||||
? 'Anbieterkennzeichnung und Kontaktangaben.'
|
||||
: 'Wie diese Website mit personenbezogenen Daten umgeht.',
|
||||
'blocks' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
public static function starter(string $title, string $type = 'page'): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -23,6 +23,8 @@ final class DocumentValidator
|
||||
public const V9_TYPES = ['breadcrumb'];
|
||||
/** Schema v11: a link to a reusable section, resolved when the page is served. */
|
||||
public const V11_TYPES = ['partialref', 'menu'];
|
||||
/** Schema v14: content from a third party, which stays unloaded until consent is given. */
|
||||
public const V14_TYPES = ['embed'];
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -32,7 +34,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -60,7 +62,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -179,6 +181,23 @@ final class DocumentValidator
|
||||
// change reaches every page that links it and a page cannot alter a shared section.
|
||||
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
|
||||
}
|
||||
if ('embed' === $type) {
|
||||
// Everything about the embed is validated here, because the address ends up in an
|
||||
// iframe and in the content security policy of the page.
|
||||
$this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.');
|
||||
// The title is what a screen reader announces for the frame, so it is not optional.
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
$this->text($props['provider'] ?? null, 80);
|
||||
$this->text($props['hint'] ?? null, 300, true);
|
||||
$url = $props['url'] ?? null;
|
||||
$this->require(is_string($url) && strlen($url) <= 400 && str_starts_with($url, 'https://'), 'Ein eingebetteter Inhalt braucht eine HTTPS-Adresse.');
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
// The host becomes part of frame-src, so it must be a plain name - no credentials, no
|
||||
// port, nothing that could smuggle a second source into the policy.
|
||||
$this->require(is_string($host) && 1 === preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/D', $host), 'Die Adresse des eingebetteten Inhalts braucht einen einfachen Hostnamen.');
|
||||
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
||||
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
||||
}
|
||||
if ('form' === $type) {
|
||||
// Only the address is content. Fields, recipient and consent text stay in the database,
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
|
||||
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Connection;
|
||||
final class PageRepository
|
||||
{
|
||||
/** Reserved because the application already answers under these paths. */
|
||||
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege'];
|
||||
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege', 'einwilligung'];
|
||||
public const MAX_DEPTH = 4;
|
||||
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings, private RichText $richText = new RichText())
|
||||
|
||||
@@ -27,6 +27,13 @@ final class SiteSettings
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
// The consent question. Off by default, because a site that loads nothing from third
|
||||
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||||
'consentBanner' => false,
|
||||
'consentGroups' => ['external'],
|
||||
'consentText' => '',
|
||||
'privacyPath' => '',
|
||||
'imprintPath' => '',
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
@@ -94,12 +101,61 @@ final class SiteSettings
|
||||
}
|
||||
$values['homePath'] = $home;
|
||||
|
||||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
||||
$values['consentGroups'] = array_values(array_intersect(
|
||||
array_keys(Consent::GROUPS),
|
||||
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
|
||||
));
|
||||
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
$path = trim(trim((string) ($input[$key] ?? '')), '/');
|
||||
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
|
||||
throw new InvalidPage('Die Adresse der Datenschutz- beziehungsweise Impressumsseite darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
||||
}
|
||||
$values[$key] = $path;
|
||||
}
|
||||
// Privacy statement and imprint are not a feature that can be switched off: a German
|
||||
// business website needs both, and both have to be reachable from every page. What the
|
||||
// CMS cannot do is write them, so it only insists that the pages exist.
|
||||
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
|
||||
if ('' !== $values[$key] && false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $values[$key]])) {
|
||||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s für die %s.', $values[$key], $label));
|
||||
}
|
||||
}
|
||||
if ($values['consentBanner']) {
|
||||
if ([] === $values['consentGroups']) {
|
||||
throw new InvalidPage('Ohne eine Gruppe gibt es nichts zu fragen. Wähle mindestens eine, oder schalte den Hinweis ab.');
|
||||
}
|
||||
// A consent banner without a way to read the details is not consent.
|
||||
if ('' === $values['privacyPath']) {
|
||||
throw new InvalidPage('Ein Einwilligungshinweis braucht die Adresse der Datenschutzseite.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mandatory pages are missing, by their settings key.
|
||||
*
|
||||
* @return array<string, string> key => label
|
||||
*/
|
||||
public function missingLegalPages(): array
|
||||
{
|
||||
$settings = $this->load();
|
||||
$missing = [];
|
||||
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
|
||||
if ('' === $settings[$key]) {
|
||||
$missing[$key] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/** The stored path of the page that answers at the site root. */
|
||||
public function homePath(): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\SiteSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Recording and changing the consent decision.
|
||||
*
|
||||
* **No CSRF token here, deliberately.** The request changes nothing on the server: it sets a
|
||||
* cookie that only affects the browser that sent it. A token would need a session, and starting a
|
||||
* session for every visitor is exactly what a consent banner exists to avoid. What is checked is
|
||||
* the target of the redirect, so the form cannot be used to bounce someone to another site.
|
||||
*/
|
||||
final class ConsentController extends AbstractController
|
||||
{
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/einwilligung', name: 'consent_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$choice = $request->request->getString('choice');
|
||||
$groups = match ($choice) {
|
||||
'all' => array_keys(Consent::GROUPS),
|
||||
'none' => [],
|
||||
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);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/einwilligung', name: 'consent_page', methods: ['GET'])]
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
if ([] === $this->consent->askedGroups()) {
|
||||
throw $this->createNotFoundException('Diese Website fragt nichts ab.');
|
||||
}
|
||||
// Deliberately without header and footer: this is a page about a decision, not a page of
|
||||
// the website, and it must work even when an area is broken.
|
||||
$response = $this->render('consent.html.twig', [
|
||||
'consent' => $this->consent->state($request),
|
||||
'settings' => $this->settings->load(),
|
||||
]);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
// The page carries no embeds and no own styles, so it keeps the strictest policy.
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to where the form was submitted from, but only within this site: an open redirect
|
||||
* would turn a consent button into a way to send people anywhere.
|
||||
*/
|
||||
private function backTo(Request $request): string
|
||||
{
|
||||
$target = $request->request->getString('back');
|
||||
if ('' === $target || !str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
@@ -19,13 +20,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
|
||||
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
@@ -80,8 +81,9 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts);
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +94,12 @@ final class PageController extends AbstractController
|
||||
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
|
||||
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false): Response
|
||||
/**
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
@@ -108,10 +115,11 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Content-Security-Policy', sprintf(
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
$allowScript ? "'self'" : "'none'",
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
));
|
||||
|
||||
return $response;
|
||||
@@ -253,6 +261,8 @@ final class PageController extends AbstractController
|
||||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
$frames = $this->frameHosts($request, ...array_values($this->areas()));
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'areas' => $this->areas(),
|
||||
'navigation' => $this->navigation(''),
|
||||
@@ -262,7 +272,7 @@ final class PageController extends AbstractController
|
||||
'base' => $base,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
|
||||
'navigation' => $this->navigation(''),
|
||||
]));
|
||||
]), frameHosts: $frames);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
@@ -329,6 +339,35 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts of embeds that may load on this request.
|
||||
*
|
||||
* An embed whose group is not granted contributes nothing: its host must not appear in the
|
||||
* policy, because the frame is not rendered either.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function frameHosts(Request $request, array ...$documents): array
|
||||
{
|
||||
$hosts = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
|
||||
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
|
||||
if (is_string($host)) {
|
||||
$hosts[$host] = true;
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return array_keys($hosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer documents. Absent areas simply render nothing, so a site without a
|
||||
* header is a valid site rather than a broken one.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -33,6 +35,9 @@ final class SettingsController extends AbstractController
|
||||
'settings' => $this->settings->load(),
|
||||
'redirects' => $this->redirects->all(),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -78,6 +83,60 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the privacy statement and the imprint, with a skeleton whose gaps are marked.
|
||||
*
|
||||
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
|
||||
* structure with every required item named and marked as still to be filled in.
|
||||
*/
|
||||
#[Route('/admin/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
public function legalPages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$settings = $this->settings->load();
|
||||
$created = [];
|
||||
try {
|
||||
foreach (DemoContent::LEGAL as $slug => $label) {
|
||||
$key = 'datenschutz' === $slug ? 'privacyPath' : 'imprintPath';
|
||||
// An existing page is never overwritten - it may already carry the real text.
|
||||
$existing = null !== $this->pages->findPublished($slug) || $this->pageExists($slug);
|
||||
if (!$existing) {
|
||||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug));
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$created[] = $label;
|
||||
}
|
||||
$settings[$key] = $slug;
|
||||
}
|
||||
$this->settings->save([...$settings, 'consentGroups' => $settings['consentGroups'], 'sameAs' => implode('
|
||||
', $settings['sameAs'])]);
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back(
|
||||
[] === $created
|
||||
? 'Die Seiten waren schon vorhanden und sind jetzt verknüpft. Der Inhalt bleibt unverändert.'
|
||||
: sprintf('%s angelegt und verknüpft. Beide Seiten sind veröffentlicht und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.', implode(' und ', $created)),
|
||||
'success',
|
||||
);
|
||||
}
|
||||
|
||||
/** Also finds a page that exists but is not published yet. */
|
||||
private function pageExists(string $path): bool
|
||||
{
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
if ($path === $page['path']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
{# Not a hint, a warning: in Germany both pages are required, and they have to be reachable from
|
||||
every page of the site. #}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Pflichtseiten fehlen</h2><span class="muted">{{ missingLegal|join(' und ') }}</span></div>
|
||||
<div class="card-body">
|
||||
<p class="notice error" role="alert">Ohne {{ missingLegal|join(' und ') }} ist diese Website in Deutschland nicht rechtskonform – beide müssen von jeder Seite aus erreichbar sein. KantSites verlinkt sie automatisch im Fuß jeder Seite, sobald sie hier eingetragen sind.</p>
|
||||
<p class="field-help">Der Knopf legt die fehlenden Seiten mit einem Gerüst an und verknüpft sie. Jede Pflichtangabe darin ist mit <code>NOCH AUSZUFÜLLEN</code> markiert – den Text musst du selbst schreiben oder von jemandem schreiben lassen, der dafür einstehen kann. Vorhandene Seiten werden nie überschrieben.</p>
|
||||
<form action="{{ path('admin_legal_pages') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<button class="primary" type="submit">Fehlende Pflichtseiten anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
@@ -56,7 +72,33 @@
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="3">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>. Höchstens dreißig Zeilen.</p>
|
||||
<button class="primary" type="submit">Unternehmensdaten speichern</button>
|
||||
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
||||
<label class="check-label" for="consentBanner"><input id="consentBanner" name="consentBanner" type="checkbox" value="1"{{ settings.consentBanner ? ' checked' : '' }}> Einwilligungshinweis anzeigen</label>
|
||||
<p class="field-help">Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.</p>
|
||||
<fieldset class="field-set">
|
||||
<legend>Worüber gefragt wird</legend>
|
||||
{% for value, label in consentGroups %}
|
||||
<label class="check-label" for="group-{{ value }}"><input id="group-{{ value }}" name="consentGroups[]" type="checkbox" value="{{ value }}"{{ value in settings.consentGroups ? ' checked' : '' }}> {{ label }}</label>
|
||||
<p class="field-help">{{ consentGroupHelp[value] }}</p>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<label for="consentText">Text im Hinweis</label>
|
||||
<textarea id="consentText" name="consentText" rows="3" maxlength="600">{{ settings.consentText }}</textarea>
|
||||
<p class="field-help">Leer lassen für den Standardtext. Er soll sagen, was geladen wird und was ohne Zustimmung passiert – nicht zum Klicken drängen.</p>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="privacyPath">Adresse der Datenschutzseite</label>
|
||||
<input id="privacyPath" name="privacyPath" maxlength="400" value="{{ settings.privacyPath }}" placeholder="datenschutz">
|
||||
<p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imprintPath">Adresse des Impressums</label>
|
||||
<input id="imprintPath" name="imprintPath" maxlength="400" value="{{ settings.imprintPath }}" placeholder="impressum">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
|
||||
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
<link rel="stylesheet" href="/assets/design.css">
|
||||
</head>
|
||||
<body class="kant">
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header">
|
||||
@@ -46,5 +47,19 @@
|
||||
{% 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} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -125,6 +125,35 @@
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elseif type == 'embed' %}
|
||||
{# Content from a third party. Until the group is granted, nothing of it is requested - no
|
||||
iframe, no image, no preconnect. That is the whole point of the consent question: the
|
||||
placeholder is not a nicety, it is what keeps the promise.
|
||||
In the editor it always stays a placeholder: the administration's policy forbids foreign
|
||||
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>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="{{ block.id }}" class="block-embed embed-blocked{{ designClasses }}">
|
||||
<p class="embed-title">{{ props.title }}</p>
|
||||
<p class="embed-hint">{{ props.hint ?: 'Dieser Inhalt wird von einem anderen Anbieter geladen. Dabei erfährt er deine IP-Adresse und welche Seite du ansiehst.' }}</p>
|
||||
<p class="embed-provider">Anbieter: <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
|
||||
{% if editing|default(false) %}
|
||||
<p class="embed-hint">Wird erst auf der veröffentlichten Seite geladen, und dort erst nach Einwilligung.</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('consent_save') }}">
|
||||
<input type="hidden" name="back" value="{{ app.request.pathInfo }}">
|
||||
<input type="hidden" name="choice" value="some">
|
||||
{# The groups already granted stay granted; this button adds this one. #}
|
||||
{% for granted in consentGate.state(app.request).groups %}{% if granted.granted %}<input type="hidden" name="groups[]" value="{{ granted.key }}">{% endif %}{% endfor %}
|
||||
<input type="hidden" name="groups[]" value="{{ props.group }}">
|
||||
<button class="consent-button" type="submit">Inhalt laden und einwilligen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{# The consent question, rendered by the server and answered by a form.
|
||||
|
||||
No JavaScript: the public pages run with script-src 'none' by default, and a banner that needs
|
||||
a script would be the one thing that breaks that. Accepting and refusing are the same kind of
|
||||
button, next to each other, with the same weight - a refusal hidden behind a link would not be
|
||||
a free choice. The per-group selection sits in a disclosure so the first view stays readable. #}
|
||||
<section class="consent-bar" role="region" aria-labelledby="consent-heading">
|
||||
<h2 id="consent-heading">Externe Inhalte und Einwilligung</h2>
|
||||
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' }}</p>
|
||||
<form method="post" action="{{ path('consent_save') }}" class="consent-form">
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<div class="consent-actions">
|
||||
<button class="consent-button" type="submit" name="choice" value="all">Alle akzeptieren</button>
|
||||
<button class="consent-button" type="submit" name="choice" value="none">Nur notwendige</button>
|
||||
</div>
|
||||
<details class="consent-details">
|
||||
<summary>Einzeln wählen</summary>
|
||||
<ul class="consent-groups">
|
||||
{% for group in consent.groups %}
|
||||
<li>
|
||||
<label for="consent-{{ group.key }}">
|
||||
<input id="consent-{{ group.key }}" type="checkbox" name="groups[]" value="{{ group.key }}"{{ group.granted ? ' checked' : '' }}>
|
||||
<span><strong>{{ group.name }}</strong> {{ group.help }}</span>
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button class="consent-button consent-choice" type="submit" name="choice" value="some">Auswahl speichern</button>
|
||||
</details>
|
||||
<p class="consent-links">
|
||||
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">Datenschutz</a>{% endif %}
|
||||
{% if consent.imprintPath %} · <a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Einwilligung{{ settings.legalName ? ' · ' ~ settings.legalName : '' }}</title>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
</head>
|
||||
<body class="kant consent-page">
|
||||
<main id="main" tabindex="-1">
|
||||
<section class="block-section">
|
||||
<h1>Deine Einwilligung</h1>
|
||||
<p>
|
||||
{% if consent.decided %}
|
||||
Du hast am <time datetime="{{ consent.decidedAt|date('c') }}">{{ consent.decidedAt|date('d.m.Y') }}</time> entschieden. Hier lässt sich das jederzeit ändern.
|
||||
{% else %}
|
||||
Es ist noch nichts entschieden. Ohne Zustimmung werden eingebettete Inhalte anderer Anbieter nicht geladen.
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>Gespeichert wird deine Entscheidung in einem Cookie dieser Website – ohne Kennung, ohne Übermittlung an Dritte und nur für ein halbes Jahr. Danach wird erneut gefragt.</p>
|
||||
|
||||
{% include 'consent-banner.html.twig' with {consent: consent, back: '/einwilligung'} only %}
|
||||
|
||||
<p class="consent-links">
|
||||
<a href="/">Zur Startseite</a>
|
||||
{% if consent.privacyPath %} · <a href="/{{ consent.privacyPath }}">Datenschutz</a>{% endif %}
|
||||
{% if consent.imprintPath %} · <a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css"><link rel="icon" href="/favicon.svg" type="image/svg+xml"></head>
|
||||
<body><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main></body></html>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main>
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
</body></html>
|
||||
@@ -33,7 +33,8 @@
|
||||
{% else %}<style>{{ pageStyles|raw }}</style>{% endif %}
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="kant">
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{# Header and footer are block documents like any page, so they are built from elements
|
||||
instead of being written into this template. #}
|
||||
@@ -58,5 +59,19 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
+223
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
@@ -1761,7 +1762,227 @@ final class CmsTest extends TestCase
|
||||
$code->assertScriptsUnchanged($added, $before);
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
/** Turns on the consent question and publishes a privacy page it can point to. */
|
||||
private function askConsent(array $groups = ['external']): void
|
||||
{
|
||||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||||
$this->site->save([
|
||||
'homePath' => 'home',
|
||||
'consentBanner' => '1',
|
||||
'consentGroups' => $groups,
|
||||
'privacyPath' => 'datenschutz',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testALegalPathMustPointAtAPageThatExists(): void
|
||||
{
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
try {
|
||||
$this->site->save(['homePath' => 'home', $key => 'gibt-es-nicht']);
|
||||
self::fail('Expected '.$key.' to be refused.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
self::assertSame('', $this->site->load()[$key]);
|
||||
}
|
||||
// A page that exists is accepted and then linked on every page.
|
||||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testWithoutEmbedsThereIsNoConsentQuestion(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
// Nothing is loaded from anywhere else, so there is nothing to ask about - and a banner
|
||||
// without a reason is only in the way.
|
||||
self::assertStringNotContainsString('consent-bar', $html);
|
||||
self::assertStringNotContainsString('Einwilligung ändern', $html);
|
||||
self::assertSame(404, $this->request('/einwilligung')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
$response = $this->request('/');
|
||||
$html = $response->getContent();
|
||||
self::assertStringContainsString('consent-bar', $html);
|
||||
// Accepting and refusing are both buttons of the same form, so neither is hidden away.
|
||||
self::assertStringContainsString('value="all"', $html);
|
||||
self::assertStringContainsString('value="none"', $html);
|
||||
self::assertStringContainsString('/datenschutz', $html);
|
||||
// No script anywhere: the strict policy stays as it is.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy'));
|
||||
|
||||
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
|
||||
self::assertSame(302, $saved->getStatusCode());
|
||||
self::assertSame('/', $saved->headers->get('Location'));
|
||||
$cookie = $saved->headers->getCookies()[0];
|
||||
self::assertSame(Consent::COOKIE, $cookie->getName());
|
||||
self::assertTrue($cookie->isHttpOnly());
|
||||
self::assertSame('lax', $cookie->getSameSite());
|
||||
self::assertStringContainsString('external', (string) $cookie->getValue());
|
||||
|
||||
// With a decision in hand the banner is gone, but the way back to it is not.
|
||||
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
|
||||
self::assertStringNotContainsString('consent-bar', $decided->getContent());
|
||||
self::assertStringContainsString('/einwilligung', $decided->getContent());
|
||||
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function consentCookies(): iterable
|
||||
{
|
||||
yield 'granted' => ['1|external|'.time(), true];
|
||||
yield 'refused' => ['1|<EMPTY>|'.time(), false];
|
||||
yield 'other group only' => ['1|statistics|'.time(), false];
|
||||
yield 'expired' => ['1|external|'.(time() - Consent::MAX_AGE - 60), false];
|
||||
yield 'older question' => ['0|external|'.time(), false];
|
||||
yield 'nonsense' => ['ganz-egal', false];
|
||||
yield 'unknown group' => ['1|erfunden|'.time(), false];
|
||||
}
|
||||
|
||||
#[DataProvider('consentCookies')]
|
||||
public function testAnEmbedLoadsOnlyWithAValidDecisionForItsGroup(string $cookie, bool $expected): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Karte');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = [
|
||||
'id' => 'karte', 'type' => 'embed',
|
||||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'OpenStreetMap', 'url' => 'https://karten.example/einbetten?ort=kant', 'hint' => '', 'height' => 400],
|
||||
];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
$response = $this->request('/', 'GET', [], [Consent::COOKIE => str_replace('<EMPTY>', '', $cookie)]);
|
||||
$html = $response->getContent();
|
||||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||||
if ($expected) {
|
||||
self::assertStringContainsString('<iframe src="https://karten.example/einbetten?ort=kant"', $html);
|
||||
self::assertStringContainsString('title="Anfahrt"', $html);
|
||||
self::assertStringContainsString('frame-src https://karten.example', $policy);
|
||||
} else {
|
||||
// Not a hidden iframe, not a lazy one: the address does not appear at all.
|
||||
self::assertStringNotContainsString('<iframe', $html);
|
||||
self::assertStringNotContainsString('karten.example/einbetten', $html);
|
||||
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
|
||||
self::assertStringContainsString("frame-src 'none'", $policy);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Karte');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = [
|
||||
'id' => 'karte', 'type' => 'embed',
|
||||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||||
];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$this->askConsent(['external', 'statistics']);
|
||||
|
||||
// Statistics were granted earlier; loading the map must not take that away.
|
||||
$before = '1|statistics|'.time();
|
||||
$html = $this->request('/', 'GET', [], [Consent::COOKIE => $before])->getContent();
|
||||
self::assertStringContainsString('name="groups[]" value="statistics"', $html);
|
||||
self::assertStringContainsString('name="groups[]" value="external"', $html);
|
||||
|
||||
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'some', 'groups' => ['statistics', 'external'], 'back' => '/']);
|
||||
$value = (string) $saved->headers->getCookies()[0]->getValue();
|
||||
self::assertStringContainsString('statistics', $value);
|
||||
self::assertStringContainsString('external', $value);
|
||||
}
|
||||
|
||||
public function testTheConsentFormNeverSendsAnyoneElsewhere(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$this->askConsent();
|
||||
foreach (['https://fremd.example/', '//fremd.example/', 'fremd.example', ''] as $target) {
|
||||
$response = $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $target]);
|
||||
self::assertSame('/', $response->headers->get('Location'), $target);
|
||||
}
|
||||
// A local path is kept, so the decision returns to the page it was made on.
|
||||
self::assertSame('/datenschutz', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/datenschutz'])->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testSwitchingTheQuestionOffLetsTheEmbedLoadAgain(): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Karte');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = [
|
||||
'id' => 'karte', 'type' => 'embed',
|
||||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||||
];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
// No question asked means nothing is withheld: otherwise turning the banner off would
|
||||
// silently empty every page that embeds something.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<iframe', $html);
|
||||
self::assertStringNotContainsString('consent-bar', $html);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>}> */
|
||||
public static function refusedEmbeds(): iterable
|
||||
{
|
||||
yield 'http' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'http://karten.example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'protocol relative' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => '//karten.example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'javascript' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'javascript:alert(1)', 'hint' => '', 'height' => 300]];
|
||||
yield 'data url' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'data:text/html,<script>alert(1)</script>', 'hint' => '', 'height' => 300]];
|
||||
yield 'with a port' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example:8443/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'with credentials' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://nutzer:geheim@karten.example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'host with a space' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'no title' => [['group' => 'external', 'title' => '', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'unknown group' => [['group' => 'erfunden', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
|
||||
yield 'height too small' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 10]];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedEmbeds')]
|
||||
public function testRefusedEmbedsNeverReachADocument(array $props): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Karte');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = ['id' => 'karte', 'type' => 'embed', 'props' => $props];
|
||||
try {
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
self::fail('Expected the embed to be refused: '.json_encode($props['url']));
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||||
}
|
||||
|
||||
public function testAnEmbedNeedsSchemaFourteen(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu alt');
|
||||
$document['schemaVersion'] = 13;
|
||||
$document['blocks'][] = [
|
||||
'id' => 'karte', 'type' => 'embed',
|
||||
'props' => ['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||||
];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testAConsentBannerNeedsAPrivacyPageThatExists(): void
|
||||
{
|
||||
foreach ([
|
||||
'without a privacy page' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => ''],
|
||||
'pointing nowhere' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'gibt-es-nicht'],
|
||||
'without a group' => ['consentBanner' => '1', 'consentGroups' => [], 'privacyPath' => 'datenschutz'],
|
||||
] as $case => $input) {
|
||||
try {
|
||||
$this->site->save([...$input, 'homePath' => 'home']);
|
||||
self::fail('Expected '.$case.' to be refused.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
self::assertFalse($this->site->load()['consentBanner'], $case);
|
||||
}
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
|
||||
{
|
||||
$previous = [];
|
||||
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
@@ -1770,7 +1991,7 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
$kernel = new Kernel('test', false);
|
||||
try {
|
||||
return $kernel->handle(Request::create($url, $method));
|
||||
return $kernel->handle(Request::create($url, $method, $body, $cookies));
|
||||
} finally {
|
||||
$kernel->shutdown();
|
||||
foreach ($previous as $key => [$server, $env]) {
|
||||
|
||||
@@ -558,6 +558,46 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseRedirects('/admin/media?seite=2');
|
||||
}
|
||||
|
||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$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"]'));
|
||||
self::assertStringContainsString('Pflichtseiten fehlen', $crawler->filter('.card-head')->first()->text());
|
||||
$token = $crawler->filter('form[action="/admin/settings/rechtliches"] input[name="_csrf_token"]')->attr('value');
|
||||
|
||||
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => 'wrong']);
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum']));
|
||||
|
||||
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]);
|
||||
self::assertResponseRedirects('/admin/settings');
|
||||
|
||||
// Both pages exist, are published, and the settings point at them.
|
||||
foreach (['datenschutz' => 'Datenschutzerklärung', 'impressum' => 'Impressum'] as $path => $title) {
|
||||
$row = $this->db->fetchAssociative('SELECT id, published_revision_id FROM page WHERE path = ?', [$path]);
|
||||
self::assertNotFalse($row, $path);
|
||||
self::assertNotNull($row['published_revision_id'], $path.' must be published: an unpublished imprint is no imprint.');
|
||||
$client->request('GET', '/'.$path);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString($title, $client->getResponse()->getContent());
|
||||
// The skeleton says plainly that it is not finished.
|
||||
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
// And they are linked from an ordinary page, without anyone editing the footer.
|
||||
$client->request('GET', '/');
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
|
||||
// Running it twice keeps the existing pages: they may already carry the real text.
|
||||
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
||||
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]);
|
||||
self::assertSame($before, $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum']));
|
||||
}
|
||||
|
||||
public function testPasswordUpgradeRefreshesTheSessionUser(): void
|
||||
{
|
||||
$provider = new UserProvider($this->db);
|
||||
|
||||
Reference in new issue
Block a user