Bilder in vier Breiten, Releasearchiv fuer Hosting ohne Composer
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Uploads werden weiterhin nach WebP umgewandelt, jetzt in vier statt drei
Breiten (480/960/1600/2400) und ohne Hochskalieren. Hintergrundbilder
waehlen ihre Variante je Geraet, weil CSS kein srcset kennt.

Dabei kam ein Fehler heraus, den erst der Browsertest zeigte: der
Dokumentpruefer erlaubte drei sources je Bild, also war mit der vierten
Variante jedes Dokument mit einem Bild ungueltig - ohne sichtbare Meldung.
Die Grenze haengt jetzt an MediaLibrary::WIDTHS.

Geteiltes Hosting hat meistens keinen Composer. scripts/build-release.php
baut deshalb auf einem Build-Rechner ein Archiv mit fertigem vendor/ ohne
Entwicklungspakete, samt SHA-256. Der Updater bricht ab, wenn ein Update
composer.lock aendert und Composer auf dem Server nicht aufrufbar ist,
statt mit einem unpassenden vendor/ weiterzulaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 22:39:08 +02:00
1 parent c046681302
commit c3bff7d936
17 files changed
+414 -26

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Vorlage für eine Installation. Der Installer schreibt daraus die echte .env und erzeugt das
# Geheimnis selbst; von Hand: diese Datei nach .env kopieren, Werte eintragen und
# `php bin/setup.php` ausführen, das APP_SECRET nach .env.local schreibt.
APP_ENV=prod
APP_DEBUG=0
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
SITE_ORIGIN=https://example.de
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
+1
View File
@@ -9,3 +9,4 @@
/.php-cs-fixer.cache
*.log
/config/reference.php
/var/releases/
+3 -3
View File
@@ -55,7 +55,7 @@ Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=
## Medien, Galerien und Effekte
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten in vier Breiten gespeichert (480, 960, 1600, 2400 px; ein schmaleres Bild bekommt weniger Stufen und wird nie hochskaliert). Originaldateien werden nicht aufgehoben. Bildbausteine liefern `srcset`, Hintergrundbilder wählen die Variante je Gerät. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
@@ -107,6 +107,6 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
+19 -1
View File
@@ -95,7 +95,7 @@ Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten (Qualität 82) bis 480/960/1600/2400 px Breite ohne Hochskalieren; ein schmaleres Original bekommt entsprechend weniger Stufen, die letzte davon in seiner eigenen Breite. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
@@ -440,6 +440,24 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
## ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026)
Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort.
**Die Bilder: ja, und jetzt eine Stufe weiter.** Ein Upload wird nie im Original gespeichert. GD decodiert ihn, dreht ihn nach EXIF gerade, behandelt Transparenz und schreibt WebP mit Qualität 82 – bisher in drei Breiten, jetzt in **vier: 480, 960, 1600, 2400**. Die vierte Stufe ist keine Kosmetik: ein vollflächiges Startseitenbild auf einem zweifach skalierten Bildschirm wurde vorher aus 1600 px hochgezogen und sah entsprechend aus. Hochskaliert wird dabei nichts – ist die Quelle 1800 px breit, ist die letzte Stufe 1800 px, nicht 2400.
Der Bildbaustein liefert `srcset` und `sizes`, der Browser wählt. **Ein Hintergrundbild kann das nicht**, denn CSS hat kein `srcset` – und genau da lag bisher die Lücke: ein Sektionshintergrund bekam immer die größte Variante, auch auf dem Telefon. Da die Gestaltung ohnehin je Gerät geschrieben wird, entscheidet nun das Gerät: Mobil 960, Tablet 1600, Desktop die größte vorhandene. Das ist die richtige Stelle dafür, weil die Regel ohnehin in einem `@media`-Block steht – es kostet kein Byte zusätzlich.
Der Preis, ehrlich benannt: **vier Stufen kosten Rechenzeit und Platz beim Upload.** Deshalb bleibt die konservative Prüfung gegen das Speicherlimit vor dem Decodieren stehen; sie ist es, die ein Hosting mit 128 MB vor einem Abbruch mitten im Schreiben bewahrt. Ein Test hat das sofort gezeigt, als er im Rahmen der gesamten Suite lief und der Speicher schon halb belegt war – das war kein Testproblem, sondern die Schutzfunktion bei der Arbeit.
**Composer: Umut hat recht, und das ändert den Auslieferweg.** Geteiltes Hosting hat in aller Regel keinen Composer, oft nicht einmal eine Shell. Die Frage stand seit `docs/UPDATE.md` offen („Composer auf dem Server oder `vendor/` im Tag?“). Sie ist jetzt entschieden: **es gibt ein Releasearchiv.** `scripts/build-release.php` läuft auf einem Rechner, der Composer hat, baut `vendor/` ohne Entwicklungspakete in eine Kopie des Projekts und schreibt `var/releases/kantsites-<Fassung>.zip` samt SHA-256. Im Archiv liegt die Anwendung und sonst nichts: keine Tests, keine Skripte, kein `var/`, kein `.env`. Das Git-Repository bleibt dabei frei von `vendor/` – wer entwickelt, bekommt keine 3000 fremden Dateien in den Diff.
Daraus folgt eine Regel für den Updater, und sie ist wichtiger als das Archiv selbst: **ein Update, das `composer.lock` ändert, bricht ab, wenn Composer auf dem Server nicht aufrufbar ist.** Vorher hätte er den Code gewechselt und wäre mit einem `vendor/` weitergelaufen, das nicht mehr zum Code passt – der Fehler wäre erst beim ersten Seitenaufruf aufgetaucht, nach dem Rauchtest, als weiße Seite. Jetzt vergleicht der Updater die Prüfsumme der Datei vor und nach dem Checkout und sagt in einem Satz, was zu tun ist: das Releasearchiv einspielen, das `vendor/` mitbringt. Danach läuft der normale Rückweg.
**Und ein Fehler, den erst der Browsertest zeigte:** der Dokumentprüfer erlaubte drei `sources` je Bild. Die vierte Variante machte damit **jedes Dokument mit einem Bild ungültig** – ohne sichtbare Meldung, denn eine abgewiesene Speicherung sieht aus wie nichts. Das ist dieselbe Familie wie Rahmenstärke und Bildrundung: eine Zahl an einer Stelle, die niemand mit der Stelle verbindet, an der sie herkommt. Die Grenze liest jetzt `MediaLibrary::WIDTHS`, damit sie sich mit der Pipeline bewegt.
**Nebenbei gefunden:** `CommandRunner` konnte unter Windows `composer` nicht starten, weil das dort eine `.bat` ist und `CreateProcess` keine Stapeldatei ausführt. Ein Kandidat über `%COMSPEC% /c` behebt es – die Argumentliste bleibt fest, es kommt nichts aus einer Anfrage hinein.
## ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
Arbeitspaket M sagte „Abfragen je Seite zählen. Messen, dann zusammenfassen.“ Messen braucht ein Messgerät, also gibt es jetzt eines.
+1 -1
View File
@@ -151,7 +151,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt
*Braucht: nichts; teils vorziehbar.*
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt).
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. ~~Bildformate prüfen.~~ — **erledigt am 12.09.2026** (ADR 040): WebP in vier Breiten, Hintergrundbilder je Gerät. Ressourcenpakete bleiben offen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umgesetzt am 12.09.2026 (ADR 035). Die Frage „Composer auf dem Server oder `vendor/` im Tag“ ist entschieden (ADR 040): ein Releasearchiv vom Build-Rechner bringt `vendor/` mit, und ein Update, das die Abhängigkeiten ändert, bricht ohne Composer ab statt halb durchzulaufen. Offen bleibt, ob das Hosting Shell-Zugriff hat.
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
+11
View File
@@ -1,5 +1,16 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026
- **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
- **Der Browsertest hat den Fehler gefunden, den die Unittests nicht sehen konnten:** der Dokumentprüfer ließ höchstens **drei** `sources` je Bild zu. Mit der vierten Variante war jedes Dokument mit einem Bild darin ungültig – und eine abgewiesene Speicherung sieht im Editor so aus, als wäre nichts passiert. Gefunden wurde er, weil ein Galeriebild im Vorschaurahmen nicht erschien. Die Grenze hängt jetzt an `MediaLibrary::WIDTHS` statt an einer Zahl, und ein Test prüft beide Seiten: vier Quellen werden angenommen, fünf abgewiesen.
- **Die Bildpipeline ist nachgerechnet, nicht behauptet:** ein Test lädt ein erzeugtes Foto hoch, öffnet jede entstandene Datei und prüft, dass sie WebP ist, dass die vier Breiten 480/960/1600/2400 entstehen und dass das Seitenverhältnis erhalten bleibt. Ein zweiter Upload mit 700 px Breite bekommt zwei Stufen – 480 und 700 –, womit „nie hochskalieren“ geprüft ist und nicht nur im Kommentar steht.
- **Ein Testfehlschlag, der keiner war:** derselbe Test fiel um, sobald er in der gesamten Suite lief, mit der Meldung „dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt“. Das ist die Schutzprüfung vor dem Decodieren, die den bereits belegten Speicher mitrechnet – sie hat richtig gehandelt. Der Test benutzt jetzt eine kleinere Quelle und hebt das Limit für seine Dauer an, weil sein Gegenstand die Pipeline ist und nicht die Schutzprüfung.
- Hintergrundbilder sind je Gerät geprüft: die Desktopregel nennt die größte Variante, die Regel im `max-width:767px`-Block die 960er.
- **Das Releasearchiv ist gebaut, nicht geplant:** `kantsites-1.2.0.zip`, 3465 Dateien, 4,4 MB, SHA-256 `950eaa4c…`. Nachgesehen wurde, was **nicht** drin ist: kein PHPUnit, keine Tests, kein `var/`, kein `.env`.
- Der Abbruch bei geänderten Abhängigkeiten ohne Composer ist mit zwei Tests belegt: geänderte `composer.lock` ohne Composer bricht ab und stellt den alten Stand wieder her; unveränderte `composer.lock` läuft durch.
- **Nicht geprüft:** das Archiv ist auf keinem echten Hostingpaket entpackt und gestartet worden – das gehört zu den Arbeitspaketen J und N. Der Installer, der es herunterlädt, existiert noch nicht.
## Ergänzung: Ladeverhalten, 12.09.2026
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
+1 -1
View File
@@ -19,7 +19,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Drei bearbeitbare Abschnittsvorlagen (Intro, zwei Leistungen, Kontaktaufruf), einklappbarer Werkzeugkasten, Struktursuche mit Vorfahren, einklappbare Teilbäume, Pfadnavigation und dauerhaft erreichbare Blockaktionen.
- Dokumentformat v2, erhaltender idempotenter v1-Konverter, gemeinsame Tokenliste für Validator/Editor/CSS und dokumentierter Migrations-/Rückweg. Bestehende v1-Seiten werden beim Öffnen nicht geändert; keine neue DB-Migration erforderlich.
- Dokumentformat v3 für Galerie, Bildbeschriftungen/-varianten und Effekte, mit erhaltendem Upgrade ohne Downgrade und Undo. v1/v2 bleiben unterstützt. Schatten, Hover-Anheben/Bildzoom und Einblenden beim Laden ohne öffentliches JavaScript; Bewegungsreduktion wird berücksichtigt.
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600 px, Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600/2400 px (ohne Hochskalieren, Hintergrundbilder geräteabhängig), Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
- Neue Medientabelle durch Migration `20260912000300` lokal angewendet. Vorher konsistentes SQLite-Backup unter `var/backups/before-media-20260912-051258.db`. Bestehende Seiten/Benutzer nicht geändert. Lokal läuft PHP 8.4 mit 8M Upload-, 10M POST- und 256M Speicherlimit; originale Uploads werden nicht gespeichert.
- Speichern/Laden von Entwürfen, separates Veröffentlichen mit Bestätigung, sichtbare Speicher-/Fehler-/Konfliktzustände, JSON-Sicherung und bewusster Server-Neuladedialog. Keine Tests schreiben in den echten Seitenbestand.
- 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.
+2 -2
View File
@@ -29,7 +29,7 @@ Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartu
3. **Sichern.** Datenbank kopieren (bei SQLite Datei, bei MySQL `mysqldump`), dazu `.env` und den aktuellen Commit-Hash, nach `var/backups/update-<zeitstempel>/`. Der Medienordner wird **nicht** mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
4. **Wartungszustand an.** Eine Datei `var/maintenance`. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und `Retry-After`; `/admin` bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
5. **Code wechseln.** `git checkout --force <tag>`. Danach prüfen, dass `VERSION` den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server läuft. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – muss `vendor/` Teil des Tags sein. **Das ist die Entscheidung, die ansteht:** entweder Composer auf dem Server, oder ein Releasebranch, in dem `vendor/` mitgeliefert wird und den ein Build-Rechner erzeugt. Beides ist vertretbar, aber es muss vor dem ersten Livegang entschieden sein, weil der Updater danach anders aussieht.
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server aufrufbar ist. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – gilt: **ändert dieses Update `composer.lock`, bricht der Updater hier ab** und verweist auf das Releasearchiv, das `vendor/` mitbringt (`scripts/build-release.php`). Ändert das Update die Abhängigkeiten nicht, läuft es ohne Composer durch. Der Vergleich läuft über die Prüfsumme der Datei vor und nach dem Checkout, nicht über Vertrauen.
7. **Migrieren.** `doctrine-migrations migrate --no-interaction`. Die Migrationen sind bewusst nicht umkehrbar: `down()` würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein `down()`.
8. **Cache.** `cache:clear --env=prod` und Aufwärmen. Der Grund steht in `docs/QUALITY.md`: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
9. **Rauchtest.** Intern `/`, `/admin/login` und `/sitemap.xml` abrufen und auf 200 prüfen, dazu eine Abfrage auf `page` und `revision`. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
@@ -64,7 +64,7 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
## Offen, bevor das gebaut wird
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
1. ~~**Composer auf dem Server oder `vendor/` im Tag?**~~ Entschieden: `vendor/` gehört nicht ins Repository, sondern in ein Releasearchiv, das ein Build-Rechner erzeugt (`scripts/build-release.php`, SHA-256 daneben). Der Updater über Git bleibt für Installationen mit Composer und für Updates ohne Abhängigkeitsänderung.
2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* Builds a release archive that runs on hosting without Composer.
*
* Umut is right: shared hosting usually has no Composer. A release therefore has to carry
* `vendor/` with it, and that folder has to be built by somebody - here, on a machine that does
* have Composer. The archive is what `install.php` downloads and what a host without Composer
* unpacks; the Git repository stays free of `vendor/`.
*
* Run: php scripts/build-release.php [--out=var/releases]
*
* The script refuses to build rather than produce something misleading: without Composer there is
* no way to get a dependency tree without development packages, and shipping the development one
* would mean an archive with PHPUnit in it.
*/
$root = dirname(__DIR__);
$options = getopt('', ['out::', 'keep-dev']);
$outDirectory = $root.'/'.trim($options['out'] ?? 'var/releases', '/');
$version = trim((string) file_get_contents($root.'/VERSION'));
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
fwrite(STDERR, "VERSION enthält keine Fassung der Form 1.2.3.\n");
exit(1);
}
/** Runs a command and returns [ok, output]; the shell is used because this is a build tool. */
$run = static function (string $command) use ($root): array {
$output = [];
$status = 0;
exec('cd '.escapeshellarg($root).' && '.$command.' 2>&1', $output, $status);
return [0 === $status, implode("\n", $output)];
};
// Composer, in the order that works on a developer machine.
$composer = null;
foreach (['php '.escapeshellarg($root.'/composer.phar'), 'composer'] as $candidate) {
[$ok] = $run($candidate.' --version');
if ($ok) {
$composer = $candidate;
break;
}
}
if (null === $composer) {
fwrite(STDERR, "Composer ist auf diesem Rechner nicht aufrufbar.\n");
fwrite(STDERR, "Ein Release wird auf einem Build-Rechner gebaut, nicht auf dem Hosting - genau deshalb gibt es dieses Skript.\n");
exit(1);
}
$staging = $outDirectory.'/kantsites-'.$version;
$archive = $outDirectory.'/kantsites-'.$version.'.zip';
if (is_dir($staging) || is_file($archive)) {
fwrite(STDERR, sprintf("Für Fassung %s gibt es schon ein Release. Erst entfernen oder VERSION erhöhen.\n", $version));
exit(1);
}
if (!is_dir($outDirectory) && !mkdir($outDirectory, 0o775, true)) {
fwrite(STDERR, sprintf("%s ließ sich nicht anlegen.\n", $outDirectory));
exit(1);
}
echo "Fassung {$version}\n";
// What belongs into a release: the application, and nothing that only a developer needs.
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
$skip = ['.git', '.github', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
$count = 0;
if (is_file($from)) {
@mkdir(dirname($to), 0o775, true);
return @copy($from, $to) ? 1 : 0;
}
foreach (scandir($from) ?: [] as $entry) {
if ('.' === $entry || '..' === $entry || in_array($entry, $skip, true)) {
continue;
}
$count += $copy($from.'/'.$entry, $to.'/'.$entry);
}
return $count;
};
$files = 0;
foreach ($include as $entry) {
if (file_exists($root.'/'.$entry)) {
$files += $copy($root.'/'.$entry, $staging.'/'.$entry);
}
}
echo "Quelldateien: {$files}\n";
// The dependencies, without the development ones - that is the whole point of building here.
$flags = isset($options['keep-dev']) ? '' : ' --no-dev';
[$ok, $output] = $run(sprintf(
'%s install%s --optimize-autoloader --no-interaction --no-progress --working-dir=%s',
$composer,
$flags,
escapeshellarg($staging),
));
if (!$ok) {
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
exit(1);
}
echo "vendor/ gebaut".($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
if (is_file($staging.'/'.$secret)) {
unlink($staging.'/'.$secret);
}
}
if (!class_exists(ZipArchive::class)) {
fwrite(STDERR, "Die PHP-Erweiterung zip fehlt; ohne sie lässt sich kein Archiv schreiben.\n");
exit(1);
}
$zip = new ZipArchive();
if (true !== $zip->open($archive, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
fwrite(STDERR, "Archiv ließ sich nicht anlegen.\n");
exit(1);
}
$entries = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
// Paths inside the archive start with the version folder, so unpacking never scatters
// files across an existing installation.
$zip->addFile($file->getPathname(), 'kantsites-'.$version.'/'.str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)));
++$entries;
}
}
$zip->close();
$hash = hash_file('sha256', $archive);
file_put_contents($archive.'.sha256', $hash.' '.basename($archive)."\n");
printf("\nArchiv: %s\n", $archive);
printf("Dateien: %d, Größe: %.1f MB\n", $entries, filesize($archive) / 1048576);
printf("SHA-256: %s\n", $hash);
echo "\nDie Prüfsumme gehört in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n";
+4 -1
View File
@@ -267,7 +267,10 @@ final class DocumentValidator
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
}
if (isset($props['sources'])) {
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
// As many entries as the media library produces widths, not one more: the list ends
// up in a srcset, and the number has to move with the pipeline instead of standing
// here as a number nobody connects to it.
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.');
$widths = [];
foreach ($props['sources'] as $source) {
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
+7 -1
View File
@@ -11,6 +11,9 @@ final class MediaLibrary
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
public const PER_PAGE = 24;
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
public const WIDTHS = [480, 960, 1600, 2400];
public function __construct(private Connection $db, private string $directory)
{
}
@@ -109,7 +112,10 @@ final class MediaLibrary
$variants = [];
$width = imagesx($source);
$height = imagesy($source);
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
// display. A smaller source simply produces fewer variants.
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
$target = imagecreatetruecolor($targetWidth, $targetHeight);
try {
+28 -7
View File
@@ -107,17 +107,17 @@ final class StyleCompiler
continue;
}
$rules = '';
$base = $this->declarations($values);
$base = $this->declarations($values, 'self', $device);
if ('' !== $base) {
$rules .= sprintf('#%s{%s}', $id, $base);
}
// Some properties belong on the images inside the block, not on the block: object-fit
// on a figure does nothing, and a radius on the figure leaves the image square.
$images = $this->declarations($values, 'img');
$images = $this->declarations($values, 'img', $device);
if ('' !== $images) {
$rules .= sprintf('#%s img{%s}', $id, $images);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
}
@@ -138,7 +138,7 @@ final class StyleCompiler
*
* @param string $target 'self' for the block, 'img' for the images inside it
*/
private function declarations(array $values, string $target = 'self'): string
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
{
$properties = StyleSchema::properties();
$out = [];
@@ -173,7 +173,7 @@ final class StyleCompiler
// The composed declarations describe the block itself; none of them has an image variant.
if ('self' === $target) {
foreach ([
$this->background($values),
$this->background($values, $device),
$this->shadow($values),
$this->textShadow($values),
$this->filter($values),
@@ -203,7 +203,7 @@ final class StyleCompiler
}
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
private function background(array $values): string
private function background(array $values, string $device = 'desktop'): string
{
$layers = [];
$overlay = $values['overlayColor'] ?? null;
@@ -223,7 +223,9 @@ final class StyleCompiler
if (isset($values['backgroundImage']) && null !== $this->media) {
$item = $this->media->find((string) $values['backgroundImage']);
if (null !== $item && [] !== $item['variants']) {
$largest = end($item['variants']);
// A phone should not download the 2400 pixel variant of a background. CSS has no
// srcset, but the rules are already written per device, so the device decides.
$largest = $this->variantFor($item['variants'], $device);
// The URL comes from the media library, never from the document.
$layers[] = sprintf('url("%s")', $largest['src']);
}
@@ -314,6 +316,25 @@ final class StyleCompiler
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
}
/**
* The variant a device should load for a background.
*
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
* largest. A picture that only has small variants simply gives what it has.
*/
private function variantFor(array $variants, string $device): array
{
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
$chosen = $variants[0];
foreach ($variants as $variant) {
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
$chosen = $variant;
}
}
return $chosen;
}
private function grid(array $values): string
{
return isset($values['gridColumns'])
+37 -2
View File
@@ -12,6 +12,9 @@ namespace App\Ops;
*/
final class CommandRunner
{
/** @var array<string, list<string>|null> */
private array $resolved = [];
public function available(): bool
{
if (!function_exists('proc_open')) {
@@ -79,9 +82,41 @@ final class CommandRunner
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
}
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
/**
* Whether an executable can be found at all, so a missing one is reported, not guessed at.
*
* On Windows several of these are batch files, and proc_open without a shell cannot start a
* .bat - which reported "composer is not available" on a machine that has it. The variants are
* tried in order, and the one that answers is remembered for the next call.
*/
public function has(string $executable, string $cwd): bool
{
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
return null !== $this->resolve($executable, $cwd);
}
/** The spelling of a command that actually starts here, or null. */
public function resolve(string $executable, string $cwd): ?array
{
if (array_key_exists($executable, $this->resolved)) {
return $this->resolved[$executable];
}
$candidates = [[$executable]];
if ('\\' === DIRECTORY_SEPARATOR) {
// On Windows composer is a .bat, and CreateProcess cannot start a batch file on its
// own - it needs the command interpreter. The argument list stays fixed, nothing from
// a request ever reaches it.
$candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable];
}
// A phar in the project is the way on hosting where nothing is installed globally.
if (is_file($cwd.'/'.$executable.'.phar')) {
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
}
foreach ($candidates as $candidate) {
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
return $this->resolved[$executable] = $candidate;
}
}
return $this->resolved[$executable] = null;
}
}
+24 -5
View File
@@ -195,6 +195,9 @@ final class Updater
return ['ok' => false, 'steps' => $steps, 'target' => null];
}
$from = $this->git(['rev-parse', 'HEAD'])['output'];
// Remembered before the checkout: if the dependencies change and Composer is missing,
// this is what tells us - vendor/ is outside Git and would silently stay old.
$lockBefore = $this->lockHash();
$step($steps, 'Ziel bestimmen', true, sprintf('%s (von %s, Fassung %s)', $target, substr($from, 0, 8), $this->version()));
if ($target === 'v'.$this->version()) {
@@ -229,13 +232,20 @@ final class Updater
throw new \RuntimeException('version');
}
if ($this->runner->has('composer', $this->root)) {
$install = $this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
$composer = $this->runner->resolve('composer', $this->root);
if (null !== $composer) {
$install = $this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
if (!$step($steps, 'Abhängigkeiten', $install['ok'], $install['ok'] ? 'composer install ausgeführt.' : $install['output'])) {
throw new \RuntimeException('composer');
}
} elseif ($lockBefore !== $this->lockHash()) {
// Without Composer the dependencies cannot be brought up to date, and this update
// needs different ones. Going on would leave an installation whose code and
// vendor/ do not match - which fails somewhere else, later, for no visible reason.
$step($steps, 'Abhängigkeiten', false, 'Dieses Update ändert die Abhängigkeiten (composer.lock), und auf diesem Server ist Composer nicht aufrufbar. Spiele stattdessen das Releasearchiv ein, das vendor/ mitbringt.');
throw new \RuntimeException('lock');
} else {
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; vendor/ kommt aus dem Tag.');
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; composer.lock ist unverändert, vendor/ bleibt gültig.');
}
$migrated = true;
@@ -312,8 +322,9 @@ final class Updater
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
}
if ($this->runner->has('composer', $this->root)) {
$this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
$composer = $this->runner->resolve('composer', $this->root);
if (null !== $composer) {
$this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
}
$this->console(['bin/console', 'cache:clear', '--env=prod'], 300);
$health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120);
@@ -352,6 +363,14 @@ final class Updater
);
}
/** The checksum of composer.lock, or an empty string when there is none. */
private function lockHash(): string
{
$file = $this->root.'/composer.lock';
return is_readable($file) ? (string) hash_file('sha256', $file) : '';
}
/** @return array{ok: bool, exit: int, output: string} */
private function git(array $arguments, int $timeout = 60): array
{
+92
View File
@@ -26,6 +26,7 @@ use App\Kernel;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -1784,6 +1785,97 @@ final class CmsTest extends TestCase
]);
}
public function testAnImageMayCarryAsManySourcesAsThePipelineProduces(): void
{
// This is the test for a bug the browser check found: the validator allowed three sources,
// the media library produced four, and every document with a picture in it was rejected -
// silently, because a rejected save looks like nothing happening.
$sources = [];
foreach (MediaLibrary::WIDTHS as $width) {
$sources[] = ['src' => '/media/'.str_repeat('a', 32).'/'.$width, 'width' => $width];
}
$image = ['id' => 'bild', 'type' => 'image', 'props' => [
'src' => end($sources)['src'], 'alt' => 'Ein Bild', 'decorative' => false,
'width' => end($sources)['width'], 'height' => 800, 'sources' => $sources,
]];
$document = DemoContent::pages()['home'];
$document['schemaVersion'] = max(3, $document['schemaVersion']);
$document['blocks'][0]['children'][] = $image;
(new DocumentValidator())->validate($document);
self::assertCount(count(MediaLibrary::WIDTHS), $sources, 'Vier Quellen sind erlaubt, nicht drei.');
// One more than the pipeline can produce stays a reason to refuse.
$last = array_key_last($document['blocks'][0]['children']);
$document['blocks'][0]['children'][$last]['props']['sources'][] = ['src' => '/media/'.str_repeat('a', 32).'/3200', 'width' => 3200];
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
public function testAnUploadBecomesWebpInSeveralWidths(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
// The upload guard compares against the memory limit, and by this point in a full suite
// run a good part of it is already used. The limit is raised for this test, because the
// subject here is the image pipeline and not the guard.
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
// A source wide enough for every step.
$image = imagecreatetruecolor(2600, 1600);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'foto.jpg', test: true));
// Four widths, all WebP, none larger than the source.
self::assertSame([480, 960, 1600, 2400], array_column($item['variants'], 'width'));
foreach ($item['variants'] as $variant) {
$path = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
self::assertSame('image/webp', getimagesize($path)['mime'], 'Jede Variante ist WebP.');
// The aspect ratio survives the resizing.
self::assertSame((int) round($variant['width'] * 1600 / 2600), $variant['height']);
self::assertGreaterThan(0, $variant['bytes']);
}
// A narrow source is not blown up: it yields fewer variants, not bigger ones.
$small = imagecreatetruecolor(700, 500);
imagepng($small, $file);
imagedestroy($small);
$narrow = $media->upload(new UploadedFile($file, 'klein.png', test: true));
self::assertSame([480, 700], array_column($narrow['variants'], 'width'));
} finally {
@unlink($file);
ini_set('memory_limit', (string) $limit);
}
}
public function testABackgroundImageUsesASmallerVariantOnSmallerScreens(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-bg-');
try {
// Smaller than the upload test above: both run in the same process, and the memory
// guard counts what is already allocated.
$image = imagecreatetruecolor(2000, 1200);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'hintergrund.jpg', test: true));
$compiler = new StyleCompiler($media);
$css = $compiler->compileDocument(['blocks' => [['id' => 'hero', 'type' => 'section', 'style' => [
'desktop' => ['backgroundImage' => $item['id']],
'mobile' => ['backgroundImage' => $item['id']],
]]]]);
// CSS has no srcset, but the rules are written per device - so the device decides.
self::assertStringContainsString('#hero{background-image:url("/media/'.$item['id'].'/2000")}', $css);
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/960")}}', $css);
} finally {
@unlink($file);
}
}
public function testEveryLongListSaysHowMuchThereIsAndLetsYouReachIt(): void
{
// Thirty revisions: a fixed cap would show some and say nothing about the rest.
+3 -1
View File
@@ -444,7 +444,9 @@ final class EditorSecurityTest extends WebTestCase
$client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'example.php', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]);
self::assertResponseStatusCodeSame(201);
$item = json_decode($client->getResponse()->getContent(), true)['item'];
self::assertSame([480, 960, 1600], array_column($item['variants'], 'width'));
// Four steps now; the last one is capped at the width of the source (1800 here), so a
// picture is never blown up beyond what was uploaded.
self::assertSame([480, 960, 1600, 1800], array_column($item['variants'], 'width'));
foreach ($item['variants'] as $variant) {
$file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
self::assertSame('image/webp', getimagesize($file)['mime']);
+30 -1
View File
@@ -136,6 +136,34 @@ final class UpdaterTest extends TestCase
self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]);
}
public function testAnUpdateThatChangesDependenciesStopsWhenComposerIsMissing(): void
{
// The installation carries the lock of 1.0.0; the new version brings a different one. On
// hosting without Composer that cannot be resolved: vendor/ is outside Git and stays old.
$this->release('1.3.0', true, 'neu');
$updater = $this->updater();
$result = $updater->update();
self::assertFalse($result['ok'], $this->explain($result));
$failed = $this->firstFailed($result);
self::assertSame('Abhängigkeiten', $failed['step']);
self::assertStringContainsString('Releasearchiv', $failed['detail']);
// Rolled back, and the site is online again on the previous version.
self::assertSame('1.0.0', $updater->version());
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
}
public function testAnUpdateWithUnchangedDependenciesRunsWithoutComposer(): void
{
// Same dependencies as the installed version, so vendor/ stays valid.
$this->release('1.4.0', true, 'alt');
$result = $this->updater()->update();
self::assertTrue($result['ok'], $this->explain($result));
self::assertStringContainsString('composer.lock ist unverändert', $this->named($result['steps'], 'Abhängigkeiten', 'step')['detail']);
}
public function testADryRunChangesNothing(): void
{
$this->release('1.1.0', true);
@@ -189,8 +217,9 @@ final class UpdaterTest extends TestCase
* Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the
* two stub commands the updater calls. `$healthy` decides whether they succeed.
*/
private function release(string $version, bool $healthy): void
private function release(string $version, bool $healthy, string $lock = 'alt'): void
{
file_put_contents($this->author.'/composer.lock', json_encode(['packages' => [$lock]]));
file_put_contents($this->author.'/VERSION', $version."\n");
file_put_contents($this->author.'/README.md', 'KantSites '.$version."\n");
// Only the installation is kept out of Git here: the stub commands live under vendor/