diff --git a/.gitignore b/.gitignore index 7128a87..f95e4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /config/reference.php /var/releases/ -# Schriften werden je Installation heruntergeladen, nicht mitgeliefert. -/public/assets/fonts/ +# Die Schriften der Pilotwebsite liegen im Repository (siehe public/assets/fonts/LIZENZ.md), +# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere +# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher. /public/assets/build/ diff --git a/docs/NEUE-WEBSITE.md b/docs/NEUE-WEBSITE.md new file mode 100644 index 0000000..d836537 --- /dev/null +++ b/docs/NEUE-WEBSITE.md @@ -0,0 +1,201 @@ +# Eine neue Website mit KantSites bauen + +Diese Anleitung richtet sich an den, der die Arbeit macht – Mensch oder Assistent. Sie sagt, in +welcher Reihenfolge etwas entsteht, welche Entscheidung wohin gehört und welche Fehler dieses +Projekt schon einmal gemacht hat. Wer sie befolgt, kommt ohne Umwege zu einer Website, die die +Prüfungen besteht. + +**Lies zuerst:** `AGENTS.md` (Projektregeln), `docs/STATUS.md` (was es gibt und was fehlt), +`docs/ARCHITECTURE.md` (warum es so ist). Diese drei sind der Stand der Dinge; diese Datei hier ist +das Kochbuch. + +--- + +## 0. Die Grundregel + +**Alles, was eine Website ausmacht, steht in der Datenbank – nicht im Code.** Seiten, Kopf- und +Fußbereich, Menüs, Formulare, Farben, Schriften, Einstellungen. Wer für eine neue Website eine +Twig-Datei anfasst oder CSS in `public/assets/site.css` schreibt, hat sich verlaufen: dann ändert +sich das Aussehen *aller* Installationen, und beim nächsten Update ist es weg. + +Ausnahme mit Ansage: eigenes CSS und eigenes Skript je Seite gibt es im Editor (Bereich „Code"), +und es landet ebenfalls in der Datenbank. + +--- + +## 1. Installation aufsetzen + +```bash +# Eigene Datenbank und eigener Medienordner - nie gegen die Entwicklungsinstallation arbeiten. +DATABASE_URL="sqlite:///…/var/kunde.db" php vendor/bin/doctrine-migrations migrate --no-interaction +DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" php bin/console cms:user:create +``` + +Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und +Datenschutzgerüst gleich mit an. + +Zum Ansehen ein eigener Server auf einem eigenen Port: + +```bash +DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" \ + SITE_ORIGIN=http://127.0.0.1:8090 php -S 127.0.0.1:8090 -t public bin/dev-router.php +``` + +`bin/dev-router.php` ist Pflicht: ohne ihn beantwortet der eingebaute Server `/sitemap.xml` und +`/robots.txt` selbst, und die Dateien kommen ohne die Kopfzeilen, die später `public/.htaccess` +setzt – jede Messung wäre dann falsch. + +--- + +## 2. Inhalte anlegen: als Skript, nicht von Hand + +Eine vollständige Website besteht aus Dutzenden Blöcken mit Hunderten Gestaltungswerten. Das klickt +niemand zweimal. **Schreibe ein Aufbauskript**, das Medien hochlädt, Dokumente zusammensetzt und +veröffentlicht – Vorlage ist das Skript, mit dem kant-it.de nachgebaut wurde. + +Drei Eigenschaften, die ein solches Skript haben muss (alle drei sind aus Schaden gelernt): + +1. **Wiederholbar.** Ein zweiter Lauf aktualisiert vorhandene Seiten, statt sie zu überspringen – + sonst ändert eine Korrektur im Skript nichts an der Website. Bilder werden am Titel + wiedererkannt, sonst wächst die Mediathek mit jedem Lauf. +2. **Eine Quelle für Adressen.** Verweise nie von Hand schreiben. Die Adresse eines Beitrags + entsteht aus derselben Funktion, die den Beitrag anlegt. Im Nachbau standen vier Links im Hero + von Hand darin – alle vier tot, weil die Beiträge längere Adressen bekamen. +3. **Über die Repository-Klassen, nicht per SQL.** `PageRepository::saveDraft()` und `publish()` + prüfen das Dokument. Ein per `INSERT` eingeschmuggeltes Dokument kann Felder enthalten, die der + Editor nie wieder öffnen kann. + +Reihenfolge im Skript: Theme → Medien → Kopf- und Fußbereich → Seiten → Beiträge → Menüs → +Formulare → Einstellungen. + +--- + +## 3. Das Dokumentformat + +Ein Dokument ist `{schemaVersion, title, description, blocks: [...]}`. Ein Block ist +`{id, type, props, style, layout, children}`. + +- **`id`** – höchstens 64 Zeichen, eindeutig im Dokument, erscheint als HTML-`id` und als + CSS-Selektor. Sprechend wählen (`hero-headline`, nicht `block-7`). +- **`type`** – siehe Liste im Editor; `section`, `row`, `column`, `heading`, `text`, `image`, + `button`, `postlist`, `slider`, `tabs`, `form`, `contact`, `stat`, `quote`, `iconcard`, … +- **`props`** – der Inhalt. Was hier steht, ist Text und Daten, nie Auszeichnung mit Wirkung. +- **`style`** – `{desktop: {...}, tablet: {...}, mobile: {...}}`, jeder Wert aus `StyleSchema`. + Seiten, Abstände und Ecken sind **Objekte** (`['top' => '10px', …]`), keine Kurzschreibweise. +- **`layout.effects`** – `{entrance: 'rise', delay: 'short'}` für das Einblenden beim Scrollen. + +**Setze `schemaVersion` auf die aktuelle Fassung** (siehe `DocumentValidator::SCHEMA_VERSION`), +sonst sind neuere Bausteine und Eigenschaften nicht erlaubt – und der Editor kann in einem zu alten +Dokument nichts einfügen. + +--- + +## 4. Gestaltung + +**Erst das Theme, dann die Blöcke.** Farben und Schriften gehören in die Palette +(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f` +sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog. + +Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war +hier schon zweimal die Ursache eines „geht nicht". + +**Die Vorschau im Editor zeigt dieselbe Ausgabe wie die Website.** Was dort falsch aussieht, ist +falsch – nicht „nur die Vorschau". + +--- + +## 5. Bilder + +- Hochladen über `MediaLibrary::upload()`; daraus entstehen WebP-Varianten in 480/960/1600/2400 px + (nie größer als das Original). Das Original wird nicht behalten. +- **Vor dem Hochladen prüfen:** über 12 Megapixel oder 8000 px Kantenlänge werden abgelehnt, und + ein Hostingpaket mit 128 MB Speicher schafft große Bilder nicht. Vorher auf 2400 px verkleinern. +- Im Dokument immer alle `sources` mitgeben, damit `srcset` entsteht. +- Ein Bild mit fester Anzeigebreite bekommt `style.desktop.maxWidth` – daraus entsteht das + `sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die + 960er Datei. +- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden. + +--- + +## 6. Beiträge + +Optional. Gibt es keine, verschwinden Archiv, Feed und Feedverweis von selbst; ein +Beitragslisten-Baustein zeigt öffentlich nichts. + +Beim Anlegen wählt man einen von drei Aufbauten (`PostLayouts`: Bildkopf, Klassisch, Magazin), für +die Liste eine von vier Darstellungen (Karten, Liste, Kompakt, Wechselseitig). Beides sind +Startpunkte, keine Schablonen. + +--- + +## 7. SEO + +Meistens fertig, wenn der Inhalt stimmt: Titel, Beschreibung, Canonical, Open Graph, strukturierte +Daten, Sitemap, `robots.txt` entstehen aus dem, was dasteht. Zu setzen bleibt: + +- **Titel für Suchergebnisse** je Seite, wenn er sich vom Titel auf der Seite unterscheiden soll. +- **Vorschaubild beim Teilen** – sonst nimmt ein Beitrag sein Beitragsbild und eine Seite gar nichts. +- **Unternehmensdaten** unter Einstellungen; daraus entsteht der strukturierte Datensatz. +- **Weiterleitungen**, wenn die Website eine alte ablöst. **Vor dem Umzug** die Adressliste der + alten Website besorgen (deren `sitemap.xml`) und für jede Adresse, die sich ändert, eine + Weiterleitung anlegen. Ohne das ist der Rang von Jahren weg. + +--- + +## 8. Schriften + +Nach dem Aufsetzen **einmal** `php bin/console cms:fonts:fetch` (oder der Knopf im Designbereich). +Ohne das lädt die Website die Schriften von Google – zwei fremde Verbindungen nacheinander und die +IP-Adresse jedes Besuchers an einen Dritten. Die Dateien der Pilotwebsite liegen im Repository; +wählt eine neue Website andere Schriften, holt dieser Befehl sie. + +--- + +## 9. Prüfen + +In dieser Reihenfolge, gegen die neue Website: + +```bash +CMS_URL=http://127.0.0.1:8090 npm run test:links # kein toter Verweis +CMS_URL=http://127.0.0.1:8090 npm run test:browser # Barrierefreiheit, Tastatur, ohne JavaScript, Ladebudget +MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8090 CMS_PATHS="/,/leistungen,/kontakt" npm run audit +``` + +Für `npm run audit` muss die Installation im **Produktionsmodus** laufen (`APP_ENV=prod`, +`APP_DEBUG=0`), sonst misst man den Debugmodus. + +Am Code geändert? Dann zusätzlich `php vendor/bin/phpunit` (beide PHP-Fassungen), +`npm run test:editor` und `composer format`. + +**Zielwerte:** Barrierefreiheit, Best Practices und SEO je 100. Performance mindestens 95, 100 +anstreben – eine bilderreiche Startseite mit Slider landet realistisch bei 96–98. + +Findet die Prüfung einen Kontrastmangel aus einer im Editor gesetzten Farbe, wird er **gemeldet und +nicht behoben**: das ist eine Entscheidung des Redakteurs, keine des Codes. + +--- + +## 10. Livegang + +`docs/LIVEGANG.md` ist die Liste. Die drei Dinge, die dort am häufigsten vergessen werden: +E-Mail-Versand einmal wirklich ausprobieren, Schriften holen, und die Weiterleitungen der alten +Adressen prüfen. + +--- + +## Was in diesem Projekt immer wieder schiefging + +Wer diese fünf kennt, spart sich die Suche: + +1. **Eine Einstellung, die speichert und nichts tut.** Das Feld war da, die Ausgabe nicht – beim + Vorschaubild fürs Teilen zwei Monate lang. **Nach jedem neuen Feld nachsehen, ob es vorne + ankommt**, und einen Test schreiben, der genau das prüft. +2. **`|default(true)` in Twig macht aus einem gespeicherten `false` ein `true`.** Für Schalter immer + `??` verwenden. +3. **Die Prüfung lief gegen die Demo, nicht gegen die Website.** Feste Pfade und Linktexte in einem + Prüfskript prüfen nichts, sobald der Inhalt anders ist. +4. **Messen statt vermuten.** Das Vorabladen des Hero-Bildes klang richtig und kostete fünf + Punkte. Jede Annahme über Geschwindigkeit wird gemessen, vorher und nachher. +5. **Der Entwicklungsserver ist nicht das Hosting.** Ohne Kompression und Cache-Kopfzeilen misst + man 90 statt 96 – und optimiert an der falschen Stelle. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6d3f528..d93414e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -34,6 +34,10 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. - **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (``, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern. +- **Die Schriften liegen im Repository** (`public/assets/fonts/`, mit `lato-OFL.txt` und `LIZENZ.md` daneben, wie die Open Font License es verlangt): auf einem frischen Server fehlt damit nichts, und es wird nie versehentlich von Google geladen. Andere Installationen holen ihre eigenen mit `cms:fonts:fetch`. +- **`docs/NEUE-WEBSITE.md`**: die Anleitung, mit der eine neue Kundenwebsite entsteht – Reihenfolge, Dokumentformat, Bilder, SEO, Prüfläufe, und am Ende die fünf Fehler, die in diesem Projekt immer wieder passiert sind. +- **Schutzkopfzeilen ergänzt und geprüft**: `Permissions-Policy` (Kamera, Mikrofon, Standort und Sensoren abgeschaltet) auf öffentlichen Seiten und in der Verwaltung; `X-Powered-By` wird entfernt – der Server nannte bisher in jeder Antwort seine genaue PHP-Fassung. Die Browserprüfung misst die Kopfzeilen jetzt, statt sie anzunehmen. +- **Behoben: der Editor kannte Schema v17, der Validator v18.** In einem aktuellen Dokument war die Beitragsnavigation nicht einfügbar. Ein Test liest die höchste Fassung jetzt aus dem Validator und vergleicht sie mit der des Editors – dieselbe Falle war schon bei v13 zugeschnappt. - **Getrennter Titel für Suchergebnisse** (Migration `20260913000100`): jede Seite und jeder Beitrag kann einen eigenen `` tragen, unabhängig von der Überschrift auf der Seite. Leer heißt weiterhin: der Titel der Seite gilt. Er zählt auch für `og:title`. - **Schriften kommen vom eigenen Server** (ADR 057): ein Knopf im Designbereich oder `php bin/console cms:fonts:fetch` lädt die gewählten Google-Schnitte als WOFF2 nach `public/assets/fonts` und schreibt die `@font-face`-Regeln daneben. Damit lädt keine Seite mehr etwas von Google – das spart zwei fremde Verbindungen nacheinander (gemessen 820 ms) und die datenschutzrechtliche Angreifbarkeit gleich mit. Ohne Dateien bleibt es beim bisherigen Verweis. - **Ein Stylesheet statt vier, und eine `.htaccess`, die etwas tut** (ADR 056): gebündelt, ohne Kommentare, mit Prüfsumme im Dateinamen; Kompression, ein Jahr Cache für Dateien mit Fassung oder Kennung in der Adresse, Schutz vor dem Ausliefern interner Dateien, HTTPS-Umleitung und HSTS vorbereitet und auskommentiert. Der Entwicklungsserver sendet dieselben Kopfzeilen, damit lokale Messungen etwas bedeuten. diff --git a/public/.htaccess b/public/.htaccess index 0e8db23..fefebdb 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -58,6 +58,10 @@ RewriteRule ^ index.php [L] <FilesMatch "\.woff2$"> Header set Access-Control-Allow-Origin "*" </FilesMatch> + # Was der Server ueber sich selbst erzaehlt, geht niemanden etwas an: die genaue PHP-Fassung + # ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste. + Header always unset X-Powered-By + Header always unset X-AspNet-Version # Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben). #Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" </IfModule> diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 41d3eb4..13d7dbb 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -114,7 +114,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // nirgends. Eine Zahl, an einer Stelle. -export const SCHEMA_VERSION = 17; +export const SCHEMA_VERSION = 18; const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION; export function upgradeDocument(document, version = 2) { diff --git a/public/assets/fonts/LIZENZ.md b/public/assets/fonts/LIZENZ.md new file mode 100644 index 0000000..c197641 --- /dev/null +++ b/public/assets/fonts/LIZENZ.md @@ -0,0 +1,25 @@ +# Schriften in diesem Ordner + +Hier liegen die Schriftdateien, die diese Website **selbst ausliefert**. Nichts davon wird zur +Laufzeit von einem fremden Server geholt: keine Verbindung zu `fonts.googleapis.com`, keine +IP-Adresse eines Besuchers, die dabei an einen Dritten geht. + +## Woher sie kommen + +`php bin/console cms:fonts:fetch` (oder der Knopf „Schriften auf diesen Server holen" im +Designbereich) lädt die im Design gewählten Schnitte einmalig von Google Fonts herunter, behält +nur die lateinischen Zeichenbereiche und schreibt `fonts.css` mit den passenden +`@font-face`-Regeln daneben. + +## Lizenz + +**Lato** steht unter der SIL Open Font License 1.1 – der vollständige Text liegt als +`lato-OFL.txt` daneben, wie die Lizenz es verlangt. Copyright (c) 2010–2014 tyPoland Łukasz +Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dateien mitzuliefern und +von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und +die Verwendung des reservierten Namens für eine geänderte Fassung. + +Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts +ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte +Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in +jedem Fall daneben. diff --git a/public/assets/fonts/fonts.css b/public/assets/fonts/fonts.css new file mode 100644 index 0000000..e9d607a --- /dev/null +++ b/public/assets/fonts/fonts.css @@ -0,0 +1,12 @@ +/* latin-ext */ +@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} diff --git a/public/assets/fonts/lato-400-latin-ext.woff2 b/public/assets/fonts/lato-400-latin-ext.woff2 new file mode 100644 index 0000000..6613fe0 Binary files /dev/null and b/public/assets/fonts/lato-400-latin-ext.woff2 differ diff --git a/public/assets/fonts/lato-400-latin.woff2 b/public/assets/fonts/lato-400-latin.woff2 new file mode 100644 index 0000000..ff60934 Binary files /dev/null and b/public/assets/fonts/lato-400-latin.woff2 differ diff --git a/public/assets/fonts/lato-700-latin-ext.woff2 b/public/assets/fonts/lato-700-latin-ext.woff2 new file mode 100644 index 0000000..fec4573 Binary files /dev/null and b/public/assets/fonts/lato-700-latin-ext.woff2 differ diff --git a/public/assets/fonts/lato-700-latin.woff2 b/public/assets/fonts/lato-700-latin.woff2 new file mode 100644 index 0000000..11de83f Binary files /dev/null and b/public/assets/fonts/lato-700-latin.woff2 differ diff --git a/public/assets/fonts/lato-900-latin-ext.woff2 b/public/assets/fonts/lato-900-latin-ext.woff2 new file mode 100644 index 0000000..f6329ce Binary files /dev/null and b/public/assets/fonts/lato-900-latin-ext.woff2 differ diff --git a/public/assets/fonts/lato-900-latin.woff2 b/public/assets/fonts/lato-900-latin.woff2 new file mode 100644 index 0000000..72a9bc3 Binary files /dev/null and b/public/assets/fonts/lato-900-latin.woff2 differ diff --git a/public/assets/fonts/lato-OFL.txt b/public/assets/fonts/lato-OFL.txt new file mode 100644 index 0000000..98383e3 --- /dev/null +++ b/public/assets/fonts/lato-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/index.php b/public/index.php index a88c923..72dc5b7 100644 --- a/public/index.php +++ b/public/index.php @@ -3,6 +3,11 @@ use App\Kernel; use Symfony\Component\HttpFoundation\Request; +// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt +// niemandem ausser dem, der nach einer Luecke genau dieser Fassung sucht. Auf gewoehnlichem +// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem. +header_remove('X-Powered-By'); + require dirname(__DIR__).'/config/bootstrap.php'; $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); $request = Request::createFromGlobals(); diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs index c5799c5..de5a478 100644 --- a/scripts/browser-check.mjs +++ b/scripts/browser-check.mjs @@ -31,6 +31,24 @@ try { if (200 === expected) assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path); } results.push({ name: 'machine readable routes reachable', paths: 3, hasPosts }); + + // Die Schutzkopfzeilen einer öffentlichen Seite. Sie fehlen lautlos, wenn jemand eine Antwort + // an der Stelle vorbeibaut, die sie setzt - deshalb werden sie gemessen und nicht angenommen. + { + const seite = await context.request.get(origin + '/'); + const kopf = seite.headers(); + const csp = kopf['content-security-policy'] ?? ''; + assert.match(csp, /default-src 'self'/, 'Content-Security-Policy fehlt'); + assert.match(csp, /object-src 'none'/, 'Plugins müssen verboten bleiben'); + assert.match(csp, /frame-ancestors 'none'/, 'Die Seite darf nicht in einen fremden Rahmen'); + assert.match(csp, /base-uri 'none'/, 'Ein eingeschleustes <base> würde jeden Verweis umlenken'); + assert.equal(kopf['x-content-type-options'], 'nosniff'); + assert.match(kopf['referrer-policy'] ?? '', /strict-origin/); + assert.match(kopf['permissions-policy'] ?? '', /camera=\(\)/); + // Und was nicht dastehen darf: die Serverkennung verrät, wonach ein Angreifer suchen kann. + assert.ok(!('x-powered-by' in kopf) || !/php/i.test(kopf['x-powered-by']), 'X-Powered-By nennt die PHP-Fassung'); + results.push({ name: 'security headers', csp: csp.length }); + } await context.close(); // A load budget, measured rather than assumed: what a page actually transfers, and how many diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index e41a57e..90a1bd0 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -141,6 +141,10 @@ final class PageController extends AbstractController $response->headers->set('Cache-Control', 'no-store'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + // Diese Website braucht weder Kamera noch Mikrofon, Standort oder Bewegungssensoren. Das + // hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer + // eingebetteten fremden Seite - die Möglichkeit, danach zu fragen. + $response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()'); $response->headers->set('Content-Security-Policy', sprintf( "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'", diff --git a/src/EventSubscriber/AdminHeadersSubscriber.php b/src/EventSubscriber/AdminHeadersSubscriber.php index ca72e15..df36436 100644 --- a/src/EventSubscriber/AdminHeadersSubscriber.php +++ b/src/EventSubscriber/AdminHeadersSubscriber.php @@ -23,6 +23,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface $headers->set('X-Robots-Tag', 'noindex, nofollow'); $headers->set('X-Content-Type-Options', 'nosniff'); $headers->set('Referrer-Policy', 'same-origin'); + $headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()'); // The preview must show the same typeface as the published page, so the font hosts are // allowed here as well. Everything else stays as narrow as on the public side. $headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 422a2a2..231fcc6 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -879,6 +879,29 @@ final class CmsTest extends TestCase self::assertStringContainsString('Nächstes Projekt', $rand); } + public function testEveryPublicAnswerCarriesItsProtectiveHeaders(): void + { + // Diese Kopfzeilen fehlen lautlos, wenn jemand eine Antwort an der Stelle vorbeibaut, die + // sie setzt. Ein Test ist billiger als die Entdeckung im Betrieb. + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1); + foreach (['/', '/gibt-es-nicht'] as $adresse) { + $antwort = $this->request($adresse); + $csp = (string) $antwort->headers->get('Content-Security-Policy'); + self::assertStringContainsString("default-src 'self'", $csp, $adresse); + self::assertStringContainsString("object-src 'none'", $csp, $adresse); + // Ein eingeschleustes <base> würde jeden Verweis der Seite umlenken. + self::assertStringContainsString("base-uri 'none'", $csp, $adresse); + self::assertStringContainsString("frame-ancestors 'none'", $csp, $adresse); + self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'), $adresse); + self::assertStringContainsString('camera=()', (string) $antwort->headers->get('Permissions-Policy'), $adresse); + } + + // Und der Vordereingang verrät nicht, welche PHP-Fassung hier läuft. + $index = (string) file_get_contents(__DIR__.'/../public/index.php'); + self::assertStringContainsString("header_remove('X-Powered-By')", $index); + self::assertStringContainsString('Header always unset X-Powered-By', (string) file_get_contents(__DIR__.'/../public/.htaccess')); + } + public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void { // Gemessen auf der nachgebauten Startseite: das Telefon lud das Hintergrundbild mit 1920 diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index 63e27b0..7706ab3 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js'; import { readFile } from 'node:fs/promises'; import { designCss } from '../scripts/build-design.mjs'; @@ -64,6 +65,12 @@ test('v1 to v2 migration preserves all content and IDs, is idempotent and reject assert.deepEqual(migrated, { ...original, schemaVersion: 2 }); assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated); assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 })); + // Der Editor muss dieselbe Fassung kennen wie der Validator - sonst laesst sich in einem + // aktuellen Dokument nichts mehr einfuegen, ohne dass irgendwo eine Fehlermeldung erscheint. + // Genau das ist hier schon zweimal passiert, zuletzt bei v18. + const validator = readFileSync(new URL('../src/Content/DocumentValidator.php', import.meta.url), 'utf8'); + const hoechste = Math.max(...[...validator.matchAll(/schemaVersion >= (\d+)/g)].map(treffer => Number(treffer[1]))); + assert.equal(SCHEMA_VERSION, hoechste, `Der Editor kennt v${SCHEMA_VERSION}, der Validator kennt v${hoechste}.`); assert.equal(original.schemaVersion, 1); });