Compare commits

...
8 Commits
Author SHA1 Message Date
kantarciandClaude Fable 5.2 a3fc768b5d Note the 1.42.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:24:31 +01:00
kantarciandClaude Fable 5.2 e3672d0b82 Release 1.42.0
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:24:31 +01:00
kantarciandClaude Fable 5.2 7d0369bada Serve images without PHP, add the 720 and 1280 steps, trim two hot paths
- Image widths are 480, 720, 960, 1280, 1600 and 2400. A phone needed
  721 pixels and got 960. Existing images receive the new steps from
  a task; stored pages pick them up at render time, so no document is
  rewritten. Mobile backgrounds use 720.
- A variant served once is mirrored to public/media, and a rewrite rule
  lets the web server deliver it from there. var/media stays the source.
- The hero preload follows the image each device really shows.
- The post navigation reads the order and then two posts instead of
  up to 500 documents.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:24:30 +01:00
kantarciandClaude Fable 5.2 dca2665138 Note the 1.41.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:47:00 +01:00
kantarciandClaude Fable 5.2 3e3d8931f6 Note the 1.41.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00
kantarciandClaude Fable 5.2 e25f2650ff Release 1.41.0
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00
kantarciandClaude Fable 5.2 0d61aa0f22 Note the 1.40.1 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:23:14 +01:00
57 changed files with 1038 additions and 131 deletions

No files matched your search

+2
View File
@@ -18,6 +18,8 @@
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
/public/assets/build/
# Das Abbild der Bildvarianten fuer den Webserver; es fuellt sich im Betrieb von selbst.
/public/media/
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
/zentrale/config.php
+1 -1
View File
@@ -1 +1 @@
1.40.1
1.42.0
+11
View File
@@ -9,6 +9,17 @@
* what public/.htaccess does. This script reproduces that behaviour for local development.
*/
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
// Wie die Regel in public/.htaccess: Ein Bild der Mediathek, das im Abbild public/media/ liegt,
// geht ohne die Anwendung raus (ADR 129). Ohne das maesse man hier weiter den Weg ueber PHP.
if (1 === preg_match('#^/media/[a-f0-9]{32}/[0-9]{1,4}$#D', $path) && is_file(__DIR__.'/../public'.$path.'.webp')) {
header('Content-Type: image/webp');
header('Cache-Control: public, max-age=31536000, immutable');
header('X-Content-Type-Options: nosniff');
header('Content-Length: '.filesize(__DIR__.'/../public'.$path.'.webp'));
readfile(__DIR__.'/../public'.$path.'.webp');
exit;
}
$file = __DIR__.'/../public'.$path;
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
+9
View File
@@ -10,6 +10,9 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
# Das Abbild der Bildvarianten, das der Webserver ohne PHP ausliefert (App\Content\MediaMirror,
# ADR 129). MEDIA_MIRROR_DIRECTORY stellt es um; in Tests ist es aus.
kant.media_mirror_dir: '%kernel.project_dir%/public/media'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
@@ -69,6 +72,10 @@ services:
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
App\Content\MediaMirror:
arguments:
$directory: '%env(default:kant.media_mirror_dir:MEDIA_MIRROR_DIRECTORY)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
# schreiben.
App\Ops\MailCheck:
@@ -209,5 +216,7 @@ services:
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test:
parameters:
# Tests schreiben nicht nach public/. Wer das Abbild prüft, setzt MEDIA_MIRROR_DIRECTORY.
kant.media_mirror_dir: ''
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587'
+17 -1
View File
@@ -1,6 +1,22 @@
# Architekturentscheidungen
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 129 – Bilder: Stufen 720 und 1280, Abbild für den Webserver (10.11.2026)
**Kontext:** Die Komplettanalyse maß auf kant-it.de rund 250 ms bis zum ersten Byte eines Bildes gegen rund 100 ms für eine statische Datei, weil jedes Bild durch die Anwendung lief (ADR 009). Zwischen den Stufen 480 und 960 lag außerdem genau der Bedarf eines Telefons (412 px × 1,75 = 721 px); Becker kam damit mobil auf einen LCP von 2,6 s.
**Entscheidung:** (1) `MediaLibrary::WIDTHS` ist `[480, 720, 960, 1280, 1600, 2400]`; eine Stufe knapp unter der eigenen Breite entfällt (`steps()`). Vorhandene Bilder bekommen die neuen Stufen über die Aufgabe „Bildgrößen ergänzen“ (`image-variants`, alle 15 Minuten, portionsweise) aus ihrer größten Variante – das Original wird nicht aufbewahrt. Bestehende Varianten und ihre Adressen bleiben. (2) Die Seite löst `props.sources` eines Bildes beim Ausliefern aus der Mediathek auf (`PageController::currentImageSources`), statt die im Dokument gespeicherte Liste zu drucken: Nachgereichte Stufen erreichen jede gespeicherte Seite ohne Dokumentmigration. (3) Hintergrundbilder nehmen auf dem Telefon die Stufe 720 statt 960; die Vorabladung löst das Bild je Gerät mit derselben Vererbung und Sichtbarkeit auf wie die Regel. (4) `MediaMirror` legt eine Variante beim ersten Abruf über PHP unter `public/media/<kennung>/<breite>.webp` ab (harter Verweis, sonst Kopie); eine Regel in `public/.htaccess` liefert sie danach ohne PHP aus. `var/media` bleibt die Quelle für Sicherung, Paket und Wiederherstellung; „Aufräumen“ und der Paketimport entfernen aus dem Abbild, was es dort nicht mehr gibt.
**Folgen:** Keine Migration, kein Schemawechsel, die Adressen der Bilder bleiben. Das hebt den Teil von ADR 009 auf, nach dem Bilder nur über die Anwendung ausgeliefert werden: Was im Abbild liegt, war auch vorher für jeden abrufbar, der die Adresse kennt (die Auslieferung prüft keine Anmeldung). Lässt sich `public/media` nicht schreiben oder greift die Regel bei einem Hoster nicht, bleibt alles beim Weg über PHP. Der eingebaute PHP-Server **ohne** Router antwortet mit 404, sobald zu einer Kennung ein Ordner im Abbild liegt; `bin/dev-router.php` bildet die Regel nach, und die Prüfskripte nutzen ein eigenes Abbild (`MEDIA_MIRROR_DIRECTORY`). Die WebP-Qualität bleibt 82. Nachgereichte Stufen sind aus einer WebP-Variante gerechnet, nicht aus dem Original.
## ADR 128 – Lesen ist nachsichtig, Schreiben streng; Kennung statt Versionsspalte (10.11.2026)
**Kontext:** Die Komplettanalyse vom 10.11.2026 fand drei Stellen, an denen ein gespeicherter Stand zur Falle wurde: Seiten wurden bei jedem Lesen voll geprüft, samt eigenem CSS – eine später verschärfte Regel ließ sie mit 500 antworten und im Editor nicht mehr öffnen; Einstellungen und Design überschrieb der zweite von zwei Admins still; Sicherungen galten als unversehrt, auch wenn Mediendateien fehlten.
**Entscheidung:** (1) `DocumentValidator` parst eigenes CSS nur noch beim Schreiben. Beim Lesen bleibt die Strukturprüfung, das CSS eines Blocks fällt in `compileCss()` weg, wenn es nicht mehr gilt – wie ADR 116 es für Bereiche schon vorsah. Ein beim Lesen geprüftes Dokument wird je Anfrage über seinen Inhalt gemerkt und nicht noch einmal geprüft. (2) `SiteSettings::stamp()` und `Theme::stamp()` liefern einen Hash des gespeicherten JSON; die Formulare tragen ihn als `expected`, `save()` vergleicht in derselben Transaktion und wirft `StaleWrite`. (3) `Backup::create()` wirft, wenn eine Mediendatei nicht vollständig ankommt, und räumt den angefangenen Ordner weg; `intact()` zählt die Mediendateien; `rotate()` zählt nur unversehrte Sicherungen und entfernt beschädigte nach einer Stunde.
**Folgen:** Keine Migration: Die Kennung ist ein Hash, keine Spalte. Ein Formular ohne `expected` (aus einem vor dem Update geladenen Bildschirm, aus Tests, aus dem Installer) speichert ungeprüft wie bisher. Eigene Skripte werden weiter auch beim Lesen geprüft. Eine Sicherung scheitert jetzt hörbar, wo sie vorher unvollständig gelang – bei vollem Speicher gibt es dann keine neue, aber die alten vollständigen bleiben. Nicht umgesetzt: der MySQL-Rückweg nach einem zurückgerollten Update (neu angelegte Tabellen bleiben stehen); alle Installationen laufen auf SQLite.
## ADR 127 – Plugins mit eigenem PHP-Code, wie bei WordPress (09.11.2026)
+50 -1
View File
@@ -1,5 +1,52 @@
# Projektstatus
## Fassung 1.42.0: Geschwindigkeit – kleinere Bilder fürs Telefon, Bilder ohne PHP (10.11.2026)
**Anlass:** Umut: „Ja, 1.42 angehen“ – das dritte Paket aus der Komplettanalyse. ADR 129.
**Erledigt (keine Migration, kein Schemawechsel):**
- P2: Bildstufen 720 und 1280. Neue Uploads sofort; vorhandene Bilder über die neue Aufgabe „Bildgrößen ergänzen“ (`image-variants`). Gespeicherte Seiten nennen die neuen Größen von selbst. Telefon-Hintergründe nehmen 720 statt 960.
- P1: Abbild `public/media/` (`MediaMirror`) und Regel in `public/.htaccess`: nach dem ersten Abruf liefert der Webserver ein Bild ohne PHP.
- P7: Die Vorabladung des Hero-Hintergrunds richtet sich je Gerät nach dem Bild, das dort wirklich gezeigt wird (eigenes Bild, „keins“, ausgeblendet).
- P6: Die Beitragsnavigation liest erst die Reihenfolge und dann nur die zwei Nachbarn, statt bis zu 500 Beiträge mit Dokument zu laden; die Grenze von 500 entfällt.
**Geprüft:** PHPUnit 701 grün (PHP 8.4, zwei neue Tests, einer erweitert), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Die Regel in `.htaccess` gegen einen echten Apache 2.4 (XAMPP, eigene Instanz auf Port 8199): Variante im Abbild kommt statisch mit `image/webp`, `immutable` und `nosniff`; eine fehlende Variante, eine andere Kennung, Seiten und Videos gehen weiter an `index.php`. Lighthouse 13.4.1 mobil mit angewandter Drosselung (`throttlingMethod: devtools`), je fünf Läufe, Becker-Daten, 1.41.0 gegen den neuen Stand:
| Seite | LCP 1.41.0 | LCP neu | Leistung | Bilder |
|---|---|---|---|---|
| `/` | 2088 ms | 1932 ms | 98 → 99 | 488 → 302 KB |
| `/gartenbau` | 3790 ms | 2283 ms | 88 → 97 | 685 → 408 KB |
| `/kontakt` | 2855 ms | 2284 ms | 95 → 97 | 211 → 147 KB |
Das ist ein lokaler Vergleich mit dem PHP-Entwicklungsserver; der Gewinn kommt hier fast ganz aus den kleineren Bildern. Was das Ausliefern ohne PHP bringt, zeigt sich erst auf dem Hosting (lokal 1 ms statt 70 ms je Bild). Der gewohnte Lauf mit simulierter Drosselung (`audit.mjs`) schwankt bei so schnellen Bildern zwischen 2,1 und 2,9 s für dieselbe Seite und taugt für diesen Vergleich nicht. Nicht geprüft: ein Hosting (IONOS, nginx vor Apache), PHP 8.2, `test:browser`, Safari, die Aufgabe im echten Takt, Felddaten.
**Bekannte Fehler / offen:** PHPs eingebauter Server ohne Router antwortet mit 404 auf ein Bild, zu dessen Kennung ein Ordner im Abbild liegt (lokal immer mit `bin/dev-router.php` starten). Aus der Analyse offen: P3 OPcache-Anzeige, P4 `no-store`, P5-Rest (Brotkrumen), P8 Layoutsprünge im Schrittformular und beim Video, P9 Stylesheet teilen; dazu Oberfläche und Barrierefreiheit der Archive.
**Nächster Schritt:** Zweig `tempo-1.42` nach `main`, Tag `v1.42.0`, Releaseeintrag – nach Umuts Freigabe. Nach dem Update auf einer Live-Seite prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
## Fassung 1.41.0: Stabilität – Altbestand lesbar, Konflikte, Sicherungen, Rückfragen (10.11.2026)
**Anlass:** Umut nach 1.40.1: „ja weiter bitte“ – das zweite Paket aus der Komplettanalyse. ADR 128.
**Erledigt (keine Migration, kein Schemawechsel):**
- F4/P5: Eine Seite, deren gespeichertes CSS einer strengeren Regel nicht mehr genügt, antwortet mit 200 und lässt sich im Editor öffnen; nur dieses CSS fällt weg. Speichern verlangt weiter gültiges CSS. Beim Lesen wird ein Dokument je Anfrage einmal geprüft statt bis zu siebenmal.
- F12: Einstellungen und Design lehnen das Speichern aus einem veralteten Formular ab (`stamp()`, Feld `expected`).
- F6: Eine Sicherung scheitert, wenn eine Mediendatei nicht vollständig ankommt, und hinterlässt keinen halben Ordner. „Unversehrt“ zählt die Medien mit. Die Rotation zählt nur unversehrte.
- F9: Beim Archiv-Update entsteht die Sicherung hinter der Wartungsseite.
- F10: Paketimport legt erst alle Medien ab und entfernt dann die alten; scheitert das, sagt die Meldung, dass die Inhalte schon die des Pakets sind, und nennt die Sicherung. Eine vorher eingeschaltete Wartung bleibt an.
- F11: `cms:restore` legt die SQLite-Datei erst daneben ab, entfernt Journal und WAL und ersetzt dann; bei einer Sicherung aus einer älteren Fassung steht der Hinweis auf die Migrationen in den Schritten.
- E2: Rückfrage im Stil der Verwaltung (`public/assets/confirm.js`, `kantConfirm({title, message, confirmLabel, variant, onConfirm})`, Formulare mit `data-confirm-title`) vor: Seite endgültig löschen, Konto löschen, Sektion entfernen, Versandprotokoll leeren, Weiterleitung entfernen, Formularfeld entfernen. Fokus liegt auf „Abbrechen“. Ohne JavaScript bleibt es ein Formular ohne Frage.
- E4: Strg+S im Editor übernimmt das Feld, in dem gerade getippt wird.
- E5: Entf löscht den gewählten Baustein nur noch, wenn der Fokus in der Vorschau oder im Strukturbaum liegt, und nie bei offenem Dialog.
**Geprüft:** PHPUnit 699 grün (PHP 8.4, vier neue Tests), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser (Playwright, Pilotkopie): Weiterleitung entfernen – Dialog mit Titel und rotem Knopf, Fokus auf „Abbrechen“, Escape lässt alles stehen und gibt den Fokus zurück, Bestätigen entfernt; 390 px ohne Überstand. Editor: getipptes CSS steht nach Strg+S im gespeicherten Entwurf; Entf bei offenem Dialog und auf einem Reiter löscht nichts, im Strukturbaum wie bisher. Aufnahmen unter `reports/1.41/`. Nicht geprüft: PHP 8.2, `test:browser`, MySQL, ein Update und ein Paketimport auf einem Hosting, ein voller Datenträger (die Abbruchwege bei Sicherung und Medientausch sind gelesen, nicht nachgestellt), die übrigen vier Rückfragen einzeln im Browser.
**Bekannte Fehler / offen:** F8 (MySQL-Rückweg lässt neue Tabellen stehen) nicht umgesetzt, da keine MySQL-Installation existiert. `test:accordion` weiter veraltet. Aus der Analyse offen: Geschwindigkeit (Bilder statisch, Bildstufen), Autosave, Einstellungen aufteilen, Symbole als SVG, Barrierefreiheit der Archive.
**Release:** Tag `v1.41.0` auf `main`, Releaseeintrag mit Zip (3706 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (d5ff3d7e…), `releases/latest` meldet v1.41.0.
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.41.0 (kant-it.de steht noch auf 1.37.0). Danach laut Bericht 1.42 (Geschwindigkeit: Bilder statisch ausliefern, Bildstufen 720/1280 mit Migrationsweg, Beitragsnavigation, Hero-Preload je Gerät).
## Fassung 1.40.1: Symfony-Sicherheitsupdate und acht Korrekturen aus der Analyse (10.11.2026)
**Anlass:** Umut nach der Komplettanalyse: „Ja, 1.40.1 bauen“. Alle Installationen laufen auf SQLite (Umut), der MySQL-Punkt ist also Vorsorge.
@@ -18,7 +65,9 @@
**Bekannte Fehler:** `test:accordion` scheitert schon vor dieser Fassung an der Auswahl im Strukturbaum (Skript kennt die Seitenleiste seit 1.11 nicht). `tests/pilot-fixture.php` migriert die Kopie nicht. Alle übrigen Punkte der Analyse sind offen.
**Nächster Schritt:** Zweig `release-1.40.1` nach `main`, Tag `v1.40.1`, Releaseeintrag – nach Umuts Freigabe. Danach 1.41 (Stabilität) laut Bericht.
**Release:** Tag `v1.40.1` auf `main`, Releaseeintrag mit Zip (3705 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (a6c1fac3…), `releases/latest` meldet v1.40.1. Die gespeicherten Git-Zugangsdaten waren abgelaufen; Umut hat sich neu angemeldet und selbst gepusht.
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.40.1 (kant-it.de steht noch auf 1.37.0). Danach 1.41 (Stabilität) laut Bericht.
## Komplettanalyse 1.40.0 (10.11.2026)
+4 -4
View File
@@ -69,10 +69,10 @@ Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt
Der Ablauf ist derselbe wie oben, nur ohne `git`:
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
2. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
3. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
4. **Wartungszustand an, dann sichern.** Datenbank und Medien nach `var/backups/`. Seit 1.41.0 hinter der Wartungsseite: Vorher entstand die Sicherung vor dem Laden, und was in dieser Zeit noch hereinkam, fehlte nach einem Zurückschalten. Scheitert die Sicherung – auch an einer einzelnen Mediendatei –, geht die Wartung wieder aus und nichts wird ersetzt.
5. **Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
7. **Zwischenspeicher leeren.**
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
+18
View File
@@ -24,6 +24,16 @@ RewriteBase /
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Bilder der Mediathek ohne PHP ausliefern (ADR 129).
#
# Ein Bild heisst /media/<kennung>/<breite>. Liegt die Variante im Abbild public/media/ - dort legt
# die Anwendung sie beim ersten Abruf ab -, beantwortet der Webserver die Anfrage selbst: kein
# PHP-Prozess, keine Datenbank. Fehlt sie, greift die Regel nicht, und alles laeuft wie bisher
# ueber index.php. Die Pruefung haengt an REQUEST_FILENAME wie die Regel darunter; nach dem
# Umschreiben endet die Adresse auf .webp und passt nicht noch einmal.
RewriteCond %{REQUEST_FILENAME}.webp -f
RewriteRule ^media/[a-f0-9]{32}/[0-9]{1,4}$ %{REQUEST_URI}.webp [L]
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
@@ -49,6 +59,10 @@ RewriteRule ^ index.php [L]
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
# Aeltere Server kennen die Endung nicht und lieferten die Bilder des Abbilds als Text aus.
<IfModule mod_mime.c>
AddType image/webp .webp
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
@@ -68,6 +82,10 @@ RewriteRule ^ index.php [L]
<FilesMatch "\.woff2$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
# Dieselbe Zeile, die die Anwendung an ein Bild haengt, wenn sie es selbst ausliefert.
<FilesMatch "\.webp$">
Header set X-Content-Type-Options "nosniff"
</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
+10 -4
View File
@@ -1155,7 +1155,13 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
.update-dialog, .confirm-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop, .confirm-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2, .confirm-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions, .confirm-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
/* Die Rückfrage vor etwas Endgültigem (confirm.js). */
.confirm-dialog { width: calc(100vw - 32px); color: var(--ink) }
.confirm-dialog p { margin: 0; color: var(--muted); line-height: 1.5 }
.confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 }
.primary.danger { background: var(--danger); border-color: var(--danger) }
.primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b }
+68
View File
@@ -0,0 +1,68 @@
/**
* Rückfrage im Stil der Verwaltung - statt confirm() des Browsers, und statt gar keiner.
*
* Ein Formular meldet sich mit data-confirm-title an (dazu optional data-confirm-text,
* data-confirm-label und data-confirm-variant="danger"). Sein Absenden wird angehalten, bis der
* Dialog bestätigt ist. Ohne JavaScript bleibt es ein gewöhnliches Formular.
*
* Skripte rufen kantConfirm({ title, message, confirmLabel, variant, onConfirm }) auf.
*/
(() => {
let dialog = null;
let pending = null;
let opener = null;
const build = () => {
dialog = document.createElement('dialog');
dialog.className = 'confirm-dialog';
dialog.setAttribute('aria-labelledby', 'confirm-dialog-title');
dialog.setAttribute('aria-describedby', 'confirm-dialog-text');
const title = document.createElement('h2'); title.id = 'confirm-dialog-title';
const text = document.createElement('p'); text.id = 'confirm-dialog-text';
const actions = document.createElement('div'); actions.className = 'dialog-actions';
const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'secondary'; cancel.textContent = 'Abbrechen'; cancel.dataset.confirmCancel = '';
const ok = document.createElement('button'); ok.type = 'button'; ok.dataset.confirmOk = '';
actions.append(cancel, ok);
dialog.append(title, text, actions);
cancel.addEventListener('click', () => dialog.close());
ok.addEventListener('click', () => { const run = pending; pending = null; dialog.close(); run?.(); });
// Escape und der Klick neben den Dialog schließen ohne Folgen.
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener('close', () => { pending = null; opener?.focus?.({ preventScroll: true }); opener = null; });
document.body.append(dialog);
};
const ask = ({ title, message = '', confirmLabel = 'Bestätigen', variant = 'primary', onConfirm }) => {
if (!dialog) build();
if (dialog.open) dialog.close();
opener = document.activeElement;
dialog.querySelector('h2').textContent = title;
const text = dialog.querySelector('p'); text.textContent = message; text.hidden = '' === message;
const ok = dialog.querySelector('[data-confirm-ok]');
ok.textContent = confirmLabel;
ok.className = 'danger' === variant ? 'primary danger' : 'primary';
pending = onConfirm;
dialog.showModal();
// Der Fokus liegt auf „Abbrechen“: Die Eingabetaste darf nichts Endgültiges auslösen.
dialog.querySelector('[data-confirm-cancel]').focus();
};
window.kantConfirm = ask;
document.addEventListener('submit', event => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return;
event.preventDefault();
const submitter = event.submitter;
ask({
title: form.dataset.confirmTitle,
message: form.dataset.confirmText || '',
confirmLabel: form.dataset.confirmLabel || submitter?.textContent.trim() || 'Bestätigen',
variant: form.dataset.confirmVariant || 'primary',
onConfirm: () => {
form.dataset.confirmed = '1';
// requestSubmit nimmt den Knopf mit, falls er einen Namen und Wert trägt.
submitter && submitter.form === form ? form.requestSubmit(submitter) : form.requestSubmit();
},
});
});
})();
+14 -2
View File
@@ -1920,7 +1920,15 @@ function onKey(event) {
if (!history || busy) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) {
if (key === 's') { event.preventDefault(); save(); }
if (key === 's') {
event.preventDefault();
// Viele Felder schreiben erst beim Verlassen ins Dokument (eigenes CSS, Zahlen im Design).
// Ohne diesen Schritt sicherte Strg+S den Stand ohne das, was gerade getippt wurde - und
// meldete trotzdem „gespeichert“.
const field = document.activeElement;
if (field && field.ownerDocument === document && inField(field) && !field.isContentEditable) field.blur();
save();
}
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (inField(event.target)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
@@ -1935,7 +1943,11 @@ function onKey(event) {
}
// Delete removes the selected element - but never while a field or the preview text is
// being typed in, and never something the sidebar buttons would refuse.
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
// Auch nicht, während ein Dialog offen ist oder der Fokus auf einem Bedienelement der
// Seitenleiste liegt: Entf auf einer Kachel der Mediathek löschte sonst den gewählten Baustein.
const where = event.target;
const onStage = !where?.closest || where.ownerDocument !== document || where === document.body || Boolean(where.closest('#structure, .canvas-scroll'));
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && onStage && !document.querySelector('dialog[open]') && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
event.preventDefault(); actions.delete(selected);
}
}
+6 -1
View File
@@ -316,7 +316,12 @@ if (root) {
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
const button = event.target.closest('[data-remove-field]');
if (!button) return;
button.closest('[data-field]').remove(); refresh(); add.focus();
const card = button.closest('[data-field]');
const name = card.querySelector('[data-field-label]')?.value.trim();
const remove = () => { card.remove(); refresh(); add.focus(); };
// Ein eben angelegtes, leeres Feld verschwindet ohne Frage; alles andere fragt nach.
if (!name || !window.kantConfirm) { remove(); return; }
window.kantConfirm({ title: `Feld „${name}“ entfernen?`, message: 'Bereits eingegangene Anfragen behalten ihre Angaben. Wirksam wird es erst mit „Speichern“.', confirmLabel: 'Feld entfernen', variant: 'danger', onConfirm: remove });
});
list.addEventListener('input', event => {
const card = event.target.closest('[data-field]');
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+2 -2
View File
@@ -8,12 +8,12 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' } });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
});
await mkdir('reports/admin-quality', { recursive: true });
let browser, page;
+1 -1
View File
@@ -7,7 +7,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -11,7 +11,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -16,7 +16,7 @@ await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
windowsHide: true,
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
stdio: 'ignore',
});
server.on('error', error => { throw error; });
+1 -1
View File
@@ -8,7 +8,7 @@ import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -12,7 +12,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -9,7 +9,7 @@ const php = process.env.PHP_BINARY || 'php';
const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
const before = await hash();
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -7,7 +7,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+33 -2
View File
@@ -9,6 +9,9 @@ final class DocumentValidator
{
private bool $writing = false;
/** @var array<string, true> beim Lesen bereits geprüfte Dokumente dieser Anfrage */
private array $passed = [];
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
{
}
@@ -59,6 +62,24 @@ final class DocumentValidator
* lässt unpassende Eigenschaften ohnehin nicht durch.
*/
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
{
// Beim Lesen wird dasselbe Dokument in einer Anfrage oft mehrfach geholt (Seite,
// Brotkrumen, strukturierte Daten). Geprüft wird es einmal; der Schlüssel ist sein Inhalt,
// ein geändertes Dokument ist also ein anderes.
$seen = null;
if (!$writing) {
$seen = hash('xxh128', serialize($document)).($requireHeading ? '1' : '0');
if (isset($this->passed[$seen])) {
return;
}
}
$this->check($document, $requireHeading, $writing);
if (null !== $seen) {
$this->passed[$seen] = true;
}
}
private function check(array $document, bool $requireHeading, bool $writing): void
{
$this->writing = $writing;
$schemaVersion = $document['schemaVersion'] ?? null;
@@ -133,7 +154,7 @@ final class DocumentValidator
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
}
try {
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
$this->check(Translations::apply($document, $locale), $requireHeading, $this->writing);
} catch (InvalidDocument $error) {
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
}
@@ -171,7 +192,17 @@ final class DocumentValidator
// and is answered in the controller, not here.
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own));
'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]);
// Eigenes CSS wird nur beim Schreiben geparst. Was gespeichert ist, galt beim Speichern;
// ist eine Regel inzwischen strenger, lässt compileCss() das CSS dieses einen Blocks
// weg (ADR 116). Hier beim Lesen zu werfen hieße: die Seite antwortet mit 500 und lässt
// sich nicht einmal mehr zum Reparieren öffnen.
if ('css' === $own) {
if ($this->writing) {
$this->code->checkCss($block[$own]);
}
} else {
$this->code->checkScript($block[$own]);
}
}
if (isset($block['style'])) {
// Free styling: any property of StyleSchema, per device, with an optional hover value.
+108 -4
View File
@@ -11,8 +11,34 @@ 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];
/**
* The widths every upload is rendered to, in WebP. A source narrower than a step is skipped.
*
* 720 und 1280 kamen mit 1.42.0 dazu: Zwischen 480 und 960 lag genau der Bereich, den ein
* Telefon braucht (412 px Breite mal 1,75 sind 721 px) - es lud deshalb die 960er Fassung mit
* fast doppelt so vielen Bildpunkten. Vorhandene Bilder bekommen die neuen Stufen über
* fillVariants() nachgereicht.
*/
public const WIDTHS = [480, 720, 960, 1280, 1600, 2400];
/** WebP-Qualität aller Varianten. */
private const QUALITY = 82;
/**
* Die Breiten, in denen ein Bild dieser Breite vorliegt: jede Stufe darunter und es selbst.
*
* Eine Stufe knapp unter der eigenen Breite entfällt - neben einem 760 px breiten Bild ist
* eine Fassung mit 720 px eine zweite Datei ohne Nutzen.
*
* @return list<int>
*/
public static function steps(int $width): array
{
$steps = array_filter(self::WIDTHS, static fn (int $step): bool => $step <= $width * 0.9);
$steps[] = min($width, max(self::WIDTHS));
return array_values(array_unique($steps));
}
/**
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
@@ -165,7 +191,7 @@ final class MediaLibrary
// 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) {
foreach (self::steps($width) as $targetWidth) {
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
$target = imagecreatetruecolor($targetWidth, $targetHeight);
try {
@@ -174,7 +200,7 @@ final class MediaLibrary
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
$paths[] = $path;
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
}
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
@@ -201,6 +227,84 @@ final class MediaLibrary
}
}
/**
* Reicht vorhandenen Bildern die Stufen nach, die es bei ihrem Upload noch nicht gab.
*
* Das Original wird nicht aufbewahrt; gerechnet wird aus der größten vorhandenen Variante.
* Bestehende Varianten bleiben unangetastet, ihre Adressen stehen in gespeicherten Seiten.
* Die Schreibversion des Bildes ändert sich nicht - ein offenes Formular mit Titel und
* Alternativtext soll daran nicht scheitern.
*
* Arbeitet portionsweise, weil ein Lauf an einem PHP-Prozess mit fremdem Zeitlimit hängt.
*
* @return array{done: int, left: int} ergänzte Bilder und solche, die noch warten
*/
public function fillVariants(int $limit = 8, float $seconds = 8.0): array
{
$start = microtime(true);
$done = 0;
$left = 0;
foreach ($this->db->fetchAllAssociative('SELECT id, variants FROM media ORDER BY created_at DESC, id DESC') as $row) {
$variants = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
if (!is_array($variants) || [] === $variants) {
continue;
}
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
$largest = end($variants);
$missing = array_diff(self::steps((int) $largest['width']), array_column($variants, 'width'));
$file = $this->directory.'/'.$row['id'].'-'.$largest['width'].'.webp';
if ([] === $missing || !is_file($file)) {
continue;
}
if ($done >= $limit || microtime(true) - $start > $seconds || !function_exists('imagecreatefromwebp')) {
++$left;
continue;
}
$memoryLimit = ini_parse_quantity((string) ini_get('memory_limit'));
if ($memoryLimit > 0 && memory_get_usage(true) + $largest['width'] * $largest['height'] * 12 + 16777216 > $memoryLimit) {
// Passt nicht in den Arbeitsspeicher dieses Hostings: Das Bild behält seine Stufen.
continue;
}
$source = @imagecreatefromwebp($file);
if (!$source) {
continue;
}
$written = [];
try {
foreach ($missing as $width) {
$height = max(1, (int) round($largest['height'] * $width / $largest['width']));
$target = imagecreatetruecolor($width, $height);
try {
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, (int) $largest['width'], (int) $largest['height']);
$path = $this->directory.'/'.$row['id'].'-'.$width.'.webp';
$written[] = $path;
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
}
$variants[] = ['src' => '/media/'.$row['id'].'/'.$width, 'width' => $width, 'height' => $height, 'bytes' => filesize($path)];
} finally {
imagedestroy($target);
}
}
} catch (\Throwable $error) {
foreach ($written as $path) {
@unlink($path);
}
throw $error;
} finally {
imagedestroy($source);
}
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
$this->db->executeStatement('UPDATE media SET variants = ? WHERE id = ?', [json_encode($variants, JSON_THROW_ON_ERROR), $row['id']]);
++$done;
}
$this->gemerkt = [];
return ['done' => $done, 'left' => $left];
}
public function update(string $id, array $data): array
{
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Content;
/**
* Legt Bildvarianten dorthin, wo der Webserver sie ohne PHP ausliefern kann.
*
* Jedes Bild einer Seite war eine Anfrage an die Anwendung: Kernel starten, Datenbank fragen,
* Datei durchreichen. Gemessen auf kant-it.de (10.11.2026) rund 250 ms bis zum ersten Byte, eine
* statische Datei daneben rund 100 ms - und jede dieser Anfragen belegt einen der wenigen
* PHP-Prozesse, die ein geteiltes Hosting gleichzeitig hergibt.
*
* Der Medienordner unter var/ bleibt die Quelle; Sicherung, Website-Paket und Wiederherstellung
* kennen nur ihn. Dieser Ordner ist ein Abbild, das sich von selbst füllt: Beim ersten Abruf über
* PHP landet die Variante hier, alle weiteren beantwortet der Webserver (Regel in
* public/.htaccess). Fehlt das Abbild oder lässt es sich nicht schreiben, bleibt alles beim Alten.
*
* Die Adressen der Bilder ändern sich nicht. Was hier liegt, war auch vorher für jeden abrufbar,
* der die Adresse kennt - die Auslieferung über PHP prüft keine Anmeldung.
*/
final class MediaMirror
{
/** @param string $directory das Abbild, üblich public/media; leer schaltet es ab */
public function __construct(private string $directory, private string $mediaDirectory)
{
}
public function enabled(): bool
{
return '' !== $this->directory;
}
/** Legt eine Variante ab. Scheitert still: das Abbild ist eine Abkürzung, keine Voraussetzung. */
public function publish(string $id, int $width, string $source): void
{
if (!$this->enabled() || 1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || $width < 1 || $width > 9999) {
return;
}
$folder = $this->directory.'/'.$id;
$target = $folder.'/'.$width.'.webp';
if (is_file($target)) {
return;
}
if (!is_dir($folder) && !@mkdir($folder, 0o775, true) && !is_dir($folder)) {
return;
}
// Ein harter Verweis kostet keinen zweiten Platz auf der Platte. Wo das Dateisystem ihn
// nicht kennt oder der Medienordner woanders liegt, wird kopiert - über eine Zwischendatei,
// damit der Webserver nie ein halbes Bild ausliefert.
if (@link($source, $target)) {
return;
}
$teil = $target.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@copy($source, $teil) && filesize($source) === filesize($teil) && @rename($teil, $target)) {
return;
}
@unlink($teil);
}
/**
* Entfernt aus dem Abbild, was es im Medienordner nicht mehr gibt - nach dem Import eines
* Website-Pakets etwa. Ohne das lieferte der Webserver Bilder weiter aus, die die Anwendung
* selbst nicht mehr kennt.
*
* @return int entfernte Dateien
*/
public function prune(): int
{
if (!$this->enabled() || !is_dir($this->directory)) {
return 0;
}
$removed = 0;
foreach (scandir($this->directory) ?: [] as $id) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || !is_dir($this->directory.'/'.$id)) {
continue;
}
foreach (scandir($this->directory.'/'.$id) ?: [] as $name) {
if ('.' === $name || '..' === $name) {
continue;
}
$width = 1 === preg_match('/^(\d{1,4})\.webp$/D', $name, $treffer) ? $treffer[1] : null;
if (null === $width || !is_file($this->mediaDirectory.'/'.$id.'-'.$width.'.webp')) {
$removed += (int) @unlink($this->directory.'/'.$id.'/'.$name);
}
}
// Gelingt nur, wenn der Ordner leer ist - genau dann soll er weg.
@rmdir($this->directory.'/'.$id);
}
return $removed;
}
}
+27 -16
View File
@@ -239,26 +239,37 @@ final class PostRepository
[$pageId, 'category'],
);
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts'];
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
$index = null;
foreach ($all as $position => $post) {
if ($post['href'] === $own) {
$index = $position;
break;
}
// Geholt wird erst nur die Reihenfolge - Kennungen, keine Dokumente - und danach genau
// das, was gezeigt wird: die zwei Nachbarn. Vorher lud jeder Aufruf eines Beitrags bis zu
// 500 veröffentlichte Fassungen mit vollem Dokument, um zwei Titel zu lesen, und ab dem
// 501. Beitrag einer Kategorie gab es gar keine Nachbarn mehr.
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
$params = ['default', $locale];
$join = '';
if ('' !== $termId) {
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
array_unshift($params, $termId);
}
if (null === $index) {
$order = $this->db->fetchFirstColumn('SELECT p.id FROM page p'.$join.' WHERE '.$where.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC', $params);
$index = array_search($pageId, $order, true);
if (false === $index) {
return ['previous' => null, 'next' => null];
}
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
$wanted = array_filter(['previous' => $order[$index - 1] ?? null, 'next' => $order[$index + 1] ?? null]);
if ([] === $wanted) {
return ['previous' => null, 'next' => null];
}
$rows = $this->db->fetchAllAssociativeIndexed(
'SELECT p.id AS kennung, p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id IN ('.implode(',', array_fill(0, count($wanted), '?')).')',
array_values($wanted),
);
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
$terms = $this->termsForMany(array_keys($rows), $language);
$post = fn (?string $id): ?array => null !== $id && isset($rows[$id]) ? $this->present($rows[$id], $images, $terms[$id] ?? ['category' => [], 'tag' => []], $language) : null;
return [
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
'previous' => $all[$index - 1] ?? null,
'next' => $all[$index + 1] ?? null,
];
return ['previous' => $post($wanted['previous'] ?? null), 'next' => $post($wanted['next'] ?? null)];
}
/**
+23 -5
View File
@@ -219,7 +219,17 @@ final class SiteSettings
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
}
public function save(array $input): void
/**
* Kennung des gespeicherten Stands. Das Formular trägt sie mit; hat inzwischen jemand anderes
* gespeichert, passt sie nicht mehr, und save() lehnt ab, statt dessen Änderung zu überschreiben.
*/
public function stamp(): string
{
return hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
}
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
public function save(array $input, ?string $expected = null): void
{
$values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page.
@@ -412,10 +422,18 @@ final class SiteSettings
}
$this->loaded = null;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
$this->db->transactional(function () use ($values, $expected): void {
if (null !== $expected) {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
throw new StaleWrite('Die Einstellungen wurden inzwischen an anderer Stelle geändert. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
}
}
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
});
}
/** Change only the 404 assignment, preserving settings saved in other forms. */
+5 -1
View File
@@ -488,7 +488,11 @@ final class StyleCompiler
*/
public static function variantFor(array $variants, string $device): array
{
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
// 720 für das Telefon seit 1.42.0: 412 px Breite mal 1,75 sind 721 px. Vorher 960 - mit
// fast doppelt so vielen Bildpunkten war der Hintergrund des Heros das, worauf das größte
// Element der Seite am längsten wartete. Ein Bild, dem diese Stufe noch fehlt,
// bekommt die nächstgrößere, also wie bisher 960.
$wanted = ['mobile' => 720, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
$chosen = $variants[0];
foreach ($variants as $variant) {
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
+20 -5
View File
@@ -108,7 +108,14 @@ final class Theme
];
}
public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void
/** Kennung des gespeicherten Stands, wie SiteSettings::stamp(). */
public function stamp(): string
{
return hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
}
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
public function save(array $colors, array $fonts, array $base, ?array $buttons = null, ?string $expected = null): void
{
$theme = [
'colors' => $this->colors($colors),
@@ -132,10 +139,18 @@ final class Theme
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
$this->db->transactional(function () use ($theme, $expected): void {
if (null !== $expected) {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
throw new \App\Domain\StaleWrite('Das Design wurde inzwischen an anderer Stelle geändert – etwa durch eine im Editor gespeicherte Stilvorlage. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
});
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
+5 -1
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\DocumentLibrary;
use App\Content\MediaLibrary;
use App\Content\MediaMirror;
use App\Content\SiteSettings;
use App\Content\VideoLibrary;
use App\Domain\StaleWrite;
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController
{
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos, private MediaMirror $mirror)
{
}
@@ -380,6 +381,9 @@ final class MediaController extends AbstractController
public function serve(string $id, int $width): Response
{
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
// Diese Anfrage kam noch bei PHP an; die nächste nach demselben Bild beantwortet der
// Webserver selbst.
$this->mirror->publish($id, $width, $path);
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
}
+76 -10
View File
@@ -323,6 +323,10 @@ final class PageController extends AbstractController
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
}
}
// Bilder nennen die Größen, die die Mediathek heute hat - nicht die vom Tag, an dem das
// Bild in die Seite kam. So erreichen nachgereichte Stufen jede gespeicherte Seite, ohne
// dass ein Dokument umgeschrieben wird.
[$page['document'], $areas, $sections] = $this->currentImageSources($page['document'], $areas, $sections);
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
@@ -1042,6 +1046,59 @@ final class PageController extends AbstractController
return $response;
}
/**
* Setzt bei jedem Bild aus der Mediathek die Liste der Größen auf ihren heutigen Stand.
*
* Angefasst wird nur ein Bild, das seine Kennung trägt und dessen Adresse zu ihr gehört; die
* Adresse und die Maße des Bildes selbst bleiben, wie sie gespeichert sind.
*
* @param array<string, array> $areas
* @param array<string, array> $sections
*
* @return array{0: array, 1: array<string, array>, 2: array<string, array>}
*/
private function currentImageSources(array $document, array $areas, array $sections): array
{
$ids = [];
$collect = static function (array $blocks) use (&$collect, &$ids): void {
foreach ($blocks as $block) {
$id = $block['props']['mediaId'] ?? null;
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'])) {
$ids[$id] = true;
}
$collect($block['children'] ?? []);
}
};
foreach ([$document, ...array_values($areas), ...array_values($sections)] as $one) {
$collect($one['blocks'] ?? []);
}
if ([] === $ids) {
return [$document, $areas, $sections];
}
$media = $this->media->findMany(array_keys($ids));
$apply = static function (array $blocks) use (&$apply, $media): array {
foreach ($blocks as $index => $block) {
$id = $block['props']['mediaId'] ?? null;
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'], $media[$id])
&& str_starts_with((string) ($block['props']['src'] ?? ''), '/media/'.$id.'/') && [] !== $media[$id]['variants']) {
$blocks[$index]['props']['sources'] = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $media[$id]['variants']);
}
if (isset($block['children'])) {
$blocks[$index]['children'] = $apply($block['children']);
}
}
return $blocks;
};
$one = static function (array $doc) use ($apply): array {
$doc['blocks'] = $apply($doc['blocks'] ?? []);
return $doc;
};
return [$one($document), array_map($one, $areas), array_map($one, $sections)];
}
/**
* The first image of the page, in the order it is rendered.
*
@@ -1069,23 +1126,32 @@ final class PageController extends AbstractController
// obere. Sonst der erste Abschnitt des Dokuments.
foreach ([$header, $document] as $kandidat) {
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
$id = $block['style']['desktop']['backgroundImage'] ?? null;
if (!is_string($id) || '' === $id) {
continue;
}
$item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) {
continue;
}
// Je Gerät das Bild, das die Regel dort wirklich nimmt: Tablet erbt vom Desktop, das
// Telefon vom Tablet - dieselbe Vererbung wie im Compiler. Vorher zählte nur der
// Desktop-Wert: Ein Telefon mit eigenem oder abgeschaltetem Hintergrund lud dann mit
// hoher Priorität ein Bild, das es nie zeigt, gegen sein eigentliches größtes Element.
$sichtbar = ['all' => [], 'desktop' => ['tablet', 'mobile'], 'tablet' => ['desktop', 'mobile'], 'mobile' => ['desktop', 'tablet'], 'hide-desktop' => ['desktop'], 'hide-tablet' => ['tablet'], 'hide-mobile' => ['mobile'], 'hidden' => ['desktop', 'tablet', 'mobile']];
$aus = $sichtbar[$block['layout']['visibility'] ?? 'all'] ?? [];
$verweise = [];
$geerbt = null;
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
$id = $block['style'][$device]['backgroundImage'] ?? $geerbt;
$geerbt = $id;
if (!is_string($id) || '' === $id || 'none' === $id || in_array($device, $aus, true)) {
continue;
}
$item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) {
continue;
}
$verweise[] = [
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
'media' => $media,
];
}
return $verweise;
if ([] !== $verweise) {
return $verweise;
}
}
return [];
+4 -2
View File
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [
'settings' => $settings,
'settingsStamp' => $this->settings->stamp(),
'businessTypes' => SiteSettings::BUSINESS_TYPES,
'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(),
@@ -124,8 +125,9 @@ final class SettingsController extends AbstractController
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->settings->save($request->request->all());
} catch (InvalidPage $e) {
// Ohne Kennung (ein Formular von vor dem Update) wird nicht geprüft, sondern gespeichert.
$this->settings->save($request->request->all(), $request->request->getString('expected') ?: null);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
+3 -2
View File
@@ -27,6 +27,7 @@ final class ThemeController extends AbstractController
return $this->render('admin/theme.html.twig', [
'theme' => $theme,
'themeStamp' => $this->theme->stamp(),
'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS,
'buttonOptions' => ButtonDesign::OPTIONS,
@@ -93,8 +94,8 @@ final class ThemeController extends AbstractController
$fonts[] = $row;
}
try {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null);
} catch (InvalidPage $e) {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null, $request->request->getString('expected') ?: null);
} catch (InvalidPage|\App\Domain\StaleWrite $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_theme');
+15 -10
View File
@@ -195,16 +195,7 @@ final class ArchiveUpdate
return ['ok' => false, 'steps' => $schritte];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
@@ -250,6 +241,20 @@ final class ArchiveUpdate
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
// Erst sichern, dann anfassen - aber erst jetzt, hinter der Wartungsseite. Vorher entstand
// die Sicherung vor dem Laden und Entpacken: Was in diesen Sekunden bis Minuten noch
// hereinkam (eine Anfrage über ein Formular, ein gespeicherter Entwurf), fehlte in ihr und
// war nach einem Zurückschalten weg.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
} catch (\Throwable $fehler) {
$this->updater->maintenance(false);
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
+72 -10
View File
@@ -69,6 +69,21 @@ final class Backup
throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory));
}
// Scheitert etwas - typisch: der Speicher ist voll -, bleibt kein halber Ordner liegen. Der
// zählte sonst bei der Rotation als eine der neuesten Sicherungen und verdrängte eine
// vollständige.
try {
return $this->write($name, $directory, $reason, $now);
} catch (\Throwable $fehler) {
$this->remove($directory);
throw $fehler;
}
}
/** @return array{name: string, path: string, manifest: array} */
private function write(string $name, string $directory, string $reason, \DateTimeImmutable $now): array
{
$database = $this->copyDatabase($directory);
$media = $this->copyMedia($directory.'/medien');
$manifest = [
@@ -88,7 +103,10 @@ final class Backup
// Not in here, and it is worth saying so where somebody reads it.
'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'],
];
file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$text = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (strlen($text) !== @file_put_contents($directory.'/'.self::MANIFEST, $text)) {
throw new InvalidPage('Das Verzeichnis der Sicherung ließ sich nicht vollständig schreiben. Freien Speicherplatz prüfen.');
}
return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
}
@@ -116,13 +134,23 @@ final class Backup
return $backups;
}
/** Removes the oldest beyond the limit. Returns the names it removed. */
/**
* Removes the oldest beyond the limit. Returns the names it removed.
*
* Gezählt werden nur unversehrte Sicherungen: eine beschädigte darf keine vollständige
* verdrängen. Beschädigte verschwinden selbst - aber erst nach einer Stunde, denn ein Ordner
* ohne Verzeichnis kann auch eine Sicherung sein, die eine andere Anfrage gerade schreibt.
*/
public function rotate(int $keep = self::KEEP): array
{
$removed = [];
foreach (array_slice($this->all(), max(1, $keep)) as $backup) {
$this->remove($backup['path']);
$removed[] = $backup['name'];
$intact = 0;
foreach ($this->all() as $backup) {
$drop = $backup['intact'] ? ++$intact > max(1, $keep) : (int) @filemtime($backup['path']) < time() - 3600;
if ($drop) {
$this->remove($backup['path']);
$removed[] = $backup['name'];
}
}
return $removed;
@@ -184,7 +212,21 @@ final class Backup
}
} else {
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
// Erst vollständig daneben ablegen, dann in einem Zug an die Stelle setzen: eine halb
// geschriebene Datenbank sieht so nie jemand. Journal und WAL der alten Datei müssen
// weg - SQLite spielte sie sonst beim nächsten Öffnen in die zurückgeholte Datei ein.
$quelle = $path.'/'.basename($file);
$neben = $file.'.wiederherstellung';
$copied = @copy($quelle, $neben) && filesize($quelle) === filesize($neben);
if ($copied) {
foreach (['-journal', '-wal', '-shm'] as $rest) {
@unlink($file.$rest);
}
// Unter Windows scheitert das Umbenennen, solange ein anderer Prozess die Datei
// offen hat; dann bleibt der bisherige Weg.
$copied = @rename($neben, $file) || @copy($neben, $file);
}
@unlink($neben);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
}
@@ -194,6 +236,12 @@ final class Backup
// by the very operation that is meant to prevent loss.
$restored = $this->restoreMedia($path.'/medien');
$step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored));
// Eine Sicherung aus einer älteren Fassung bringt deren Datenbankschema mit. Der Code hier
// ist neuer und erwartet Spalten, die es dann noch nicht gibt - das muss dastehen, sonst
// sucht man die 500er an der falschen Stelle.
if (version_compare((string) $manifest['fassung'], $this->version(), '<')) {
$step($steps, 'Migrationen', true, sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s: Jetzt in der Verwaltung unter System „Migrationen ausführen“ – oder: php bin/console doctrine:migrations:migrate.', $manifest['fassung'], $this->version()));
}
return ['steps' => $steps, 'ok' => true];
}
@@ -303,10 +351,17 @@ final class Backup
$count = 0;
$bytes = 0;
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $target.'/'.basename($file))) {
++$count;
$bytes += (int) filesize($file);
if (!is_file($file)) {
continue;
}
$size = (int) filesize($file);
// Eine Kopie, die fehlt oder kürzer ist, ist keine: lieber gar keine Sicherung als
// eine, die vollständig aussieht und beim Zurückspielen Bilder vermissen lässt.
if (!@copy($file, $target.'/'.basename($file)) || $size !== (int) @filesize($target.'/'.basename($file))) {
throw new InvalidPage(sprintf('Die Mediendatei %s ließ sich nicht sichern. Freien Speicherplatz und Schreibrechte auf var/backups prüfen.', basename($file)));
}
++$count;
$bytes += $size;
}
return ['count' => $count, 'bytes' => $bytes];
@@ -350,7 +405,14 @@ final class Backup
return false;
}
return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file));
if (!hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file))) {
return false;
}
// Auch die Medien gehören dazu: fehlt eine der gezählten Dateien, ist die Sicherung nicht
// mehr die, die das Verzeichnis beschreibt.
$erwartet = $manifest['medien']['dateien'] ?? null;
return !is_int($erwartet) || $erwartet <= count(array_filter(glob($path.'/medien/*') ?: [], 'is_file'));
}
private function size(string $path): int
+57 -18
View File
@@ -3,6 +3,7 @@
namespace App\Ops;
use App\Content\DocumentValidator;
use App\Content\MediaMirror;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
@@ -49,6 +50,7 @@ final class SitePackage
private Updater $updater,
private string $root,
private string $mediaDirectory,
private ?MediaMirror $mirror = null,
) {
}
@@ -207,19 +209,36 @@ final class SitePackage
return $fail();
}
// Eine Wartung, die schon vorher an war, hat jemand mit Absicht eingeschaltet - die
// hebt ein Import nicht auf.
$inWartung = is_file($this->root.'/'.Updater::MAINTENANCE_FILE);
$this->updater->maintenance(true);
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
$moved = $this->replaceMedia($media);
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
return $fail();
return $fail();
}
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
// Ab hier ist die Datenbank die des Pakets. Scheitern die Medien, sagt die Meldung
// genau das - und nennt die Sicherung, mit der sich beides zurücknehmen lässt.
try {
$moved = $this->replaceMedia($media);
} catch (\Throwable $e) {
$step('Medien ersetzen', false, sprintf('%s Die Inhalte sind bereits die des Pakets, die Medien nicht vollständig. Zurück auf den Stand vorher: cms:restore %s.', $e->getMessage(), $safety['name']));
return $fail();
}
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
// Was der Webserver noch aus dem Abbild lieferte, gehört zur Website von vorher.
$this->mirror?->prune();
} finally {
$this->updater->maintenance(false);
if (!$inWartung) {
$this->updater->maintenance(false);
}
if (isset($media)) {
$this->remove($media);
}
@@ -435,25 +454,45 @@ final class SitePackage
return $target;
}
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
/**
* Legt die Dateien des Pakets in den Medienordner und entfernt danach, was nicht dazugehört.
*
* In dieser Reihenfolge: Erst wenn jede Datei des Pakets angekommen ist, verschwinden die
* alten. Vorher wurde zuerst geleert und dann kopiert, ohne nachzuzählen - bei knappem
* Speicher meldete der Import Erfolg, und die Website verwies auf Bilder, die es nicht gab.
*
* @throws \RuntimeException wenn eine Datei nicht ankommt; der Medienordner hat dann noch alle alten Dateien
*/
private function replaceMedia(string $source): int
{
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.');
}
$neu = [];
foreach (scandir($source) ?: [] as $name) {
if (1 !== preg_match(self::MEDIA_NAME, $name)) {
continue;
}
$size = (int) filesize($source.'/'.$name);
$target = $this->mediaDirectory.'/'.$name;
// Verschieben braucht keinen zweiten Platz auf der Platte; über Laufwerksgrenzen
// hinweg bleibt das Kopieren.
if (!@rename($source.'/'.$name, $target) && !@copy($source.'/'.$name, $target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s ließ sich nicht ablegen. Freien Speicherplatz prüfen.', $name));
}
clearstatcache(true, $target);
if ($size !== (int) @filesize($target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s kam unvollständig an. Freien Speicherplatz prüfen.', $name));
}
$neu[$name] = true;
}
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name)) {
if (!isset($neu[$name]) && 1 === preg_match(self::MEDIA_NAME, $name)) {
@unlink($this->mediaDirectory.'/'.$name);
}
}
$count = 0;
foreach (scandir($source) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name) && @copy($source.'/'.$name, $this->mediaDirectory.'/'.$name)) {
++$count;
}
}
return $count;
return \count($neu);
}
private function summary(array $tables, bool $withUsers): string
+7 -2
View File
@@ -2,6 +2,7 @@
namespace App\Ops\Tasks;
use App\Content\MediaMirror;
use App\Ops\ArchiveUpdate;
use App\Ops\Scheduler;
use App\Ops\Updater;
@@ -26,7 +27,7 @@ final class CleanupTask implements Task
/** So viele Zeilen bleiben in den Protokollen stehen. */
private const KEEP_LINES = 200;
public function __construct(private string $projectDir)
public function __construct(private string $projectDir, private ?MediaMirror $mirror = null)
{
}
@@ -57,12 +58,16 @@ final class CleanupTask implements Task
{
$limit = time() - self::KEEP_DAYS * 86400;
return sprintf(
$bericht = sprintf(
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
$this->sessions($limit),
$this->updateFolders($limit),
$this->logs(),
);
// Bildvarianten im öffentlichen Abbild, deren Bild es nicht mehr gibt.
$verwaist = $this->mirror?->prune() ?? 0;
return 0 === $verwaist ? $bericht : $bericht.sprintf(' %d verwaiste Bilddatei(en) aus public/media entfernt.', $verwaist);
}
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Ops\Tasks;
use App\Content\MediaLibrary;
/**
* Reicht vorhandenen Bildern Größen nach, die es bei ihrem Upload noch nicht gab.
*
* Mit 1.42.0 kamen die Stufen 720 und 1280 dazu. Neue Uploads bekommen sie sofort; für alles,
* was schon in der Mediathek liegt, rechnet diese Aufgabe sie portionsweise aus der größten
* vorhandenen Variante. Ist nichts mehr offen, kostet ein Lauf eine Abfrage.
*/
final class ImageVariantsTask implements Task
{
public function __construct(private MediaLibrary $media)
{
}
public function name(): string
{
return 'image-variants';
}
public function label(): string
{
return 'Bildgrößen ergänzen';
}
public function explanation(): string
{
return 'Ohne sie laden Telefone bei älteren Bildern eine größere Fassung als nötig. Angezeigt wird trotzdem alles.';
}
public function interval(): int
{
return 900;
}
public function run(): string
{
$ergebnis = $this->media->fillVariants();
if (0 === $ergebnis['done'] && 0 === $ergebnis['left']) {
return 'Alle Bilder haben alle Größen.';
}
return sprintf('%d Bild(er) ergänzt, %d warten auf den nächsten Lauf.', $ergebnis['done'], $ergebnis['left']);
}
}
+1
View File
@@ -23,6 +23,7 @@
{%- endfor %}
}}</script>
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
<script src="/assets/confirm.js?v={{ kant_version() }}" defer></script>
{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts`
+1 -1
View File
@@ -170,7 +170,7 @@
{# The class is what the security test looks for: an editor must not even be
offered this. #}
<div class="delete-panel">
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post" data-confirm-title="„{{ page.title }}“ endgültig löschen?" data-confirm-text="Die Seite wird mit allen Fassungen gelöscht. Das lässt sich nicht rückgängig machen." data-confirm-label="Endgültig löschen" data-confirm-variant="danger">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
+1 -1
View File
@@ -123,7 +123,7 @@
{% if mail.protokoll is empty %}
<span class="muted">var/log/mail.log</span>
{% else %}
<form action="{{ path('admin_mail_clear') }}" method="post">
<form action="{{ path('admin_mail_clear') }}" method="post" data-confirm-title="Versandprotokoll leeren?" data-confirm-text="Alle Einträge in var/log/mail.log werden entfernt." data-confirm-label="Protokoll leeren" data-confirm-variant="danger">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="text-button" type="submit">Protokoll leeren</button>
</form>
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="sheet-part">
<h4>Entfernen</h4>
<p>Entfernt „{{ partial.name }}“.{% if partial.usedBy is not empty %} <strong>Noch eingebunden in {{ partial.usedBy|join(', ') }}</strong> – dort erscheint danach nichts mehr.{% endif %}</p>
<form action="{{ path('admin_partial_delete', {id: partial.id}) }}" method="post">
<form action="{{ path('admin_partial_delete', {id: partial.id}) }}" method="post" data-confirm-title="Sektion „{{ partial.name }}“ entfernen?" data-confirm-text="{% if partial.usedBy is not empty %}Sie ist noch eingebunden in {{ partial.usedBy|join(', ') }} – dort erscheint danach nichts mehr. {% endif %}Das lässt sich nicht rückgängig machen." data-confirm-label="Sektion entfernen" data-confirm-variant="danger">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('partials') }}">
<button class="subtle danger-text" type="submit">Sektion entfernen</button>
</form>
+2 -1
View File
@@ -104,6 +104,7 @@
{% if is_granted('ROLE_ADMIN') %}
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="expected" value="{{ settingsStamp }}">
<div class="fields">
<div>
<label for="legalName">Name des Unternehmens</label>
@@ -390,7 +391,7 @@
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{{ entry.status }}</td>
<td>
<form action="{{ path('admin_redirect_save') }}" method="post">
<form action="{{ path('admin_redirect_save') }}" method="post" data-confirm-title="Weiterleitung von /{{ entry.from }} entfernen?" data-confirm-text="Die alte Adresse antwortet danach mit „nicht gefunden“." data-confirm-label="Entfernen" data-confirm-variant="danger">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="remove" value="{{ entry.id }}">
<button class="text-button danger-text" type="submit" aria-label="Weiterleitung von /{{ entry.from }} entfernen">Entfernen</button>
+1
View File
@@ -82,6 +82,7 @@
{% set emptyFont = {key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]} %}
<form action="{{ path('admin_theme_save') }}" method="post" class="theme-form stack" data-theme-editor data-palettes="{{ palettes|json_encode|e('html_attr') }}" data-color-names="{{ colorNames|json_encode|e('html_attr') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
<input type="hidden" name="expected" value="{{ themeStamp }}">
<section class="card">
<div class="card-head">
+1 -1
View File
@@ -87,7 +87,7 @@
<div class="sheet-part">
<h4>Konto löschen</h4>
<p>Entfernt „{{ account.username }}“. Gespeicherte Inhalte bleiben unberührt – Seiten und Fassungen hängen nicht am Konto. Das eigene Konto lässt sich nicht löschen; deshalb bleibt immer mindestens ein Administrator übrig.</p>
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post">
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post" data-confirm-title="Konto „{{ account.username }}“ löschen?" data-confirm-text="Die Person kann sich danach nicht mehr anmelden. Seiten und Fassungen bleiben." data-confirm-label="Konto löschen" data-confirm-variant="danger">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
<button class="subtle danger-text" type="submit">Konto löschen</button>
</form>
+1 -1
View File
@@ -95,7 +95,7 @@ final class ArchiveUpdateTest extends TestCase
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
self::assertSame(
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
['Fassung ermitteln', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Sicherung', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
array_column($ergebnis['steps'], 'step'),
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
);
+60
View File
@@ -12,6 +12,7 @@ use App\Content\PostRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\StaleWrite;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\ArchiveUpdate;
@@ -163,6 +164,65 @@ final class BacklogTest extends TestCase
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
public function testAPageWhoseStoredCssNoLongerPassesStaysReachableAndEditable(): void
{
// Gespeichert wurde, was beim Speichern galt. Ist eine Regel inzwischen strenger, fällt das
// CSS dieses einen Blocks weg (ADR 116) - die Seite antwortet nicht mit 500 und lässt sich
// zum Reparieren öffnen. Vorher warf schon das Lesen.
$document = DemoContent::starter('Altbestand');
$document['blocks'][] = ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => 'color: red;'];
$this->publishHome($document);
$alt = '& ~ p { color: red }';
try {
(new DocumentValidator())->validate([...$document, 'schemaVersion' => 23, 'blocks' => [...array_slice($document['blocks'], 0, -1), ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => $alt]]], writing: true);
self::fail('Dieses CSS muss beim Speichern abgelehnt werden, sonst prüft der Test nichts.');
} catch (InvalidDocument) {
}
foreach ($this->db->fetchAllAssociative('SELECT id, document FROM revision WHERE page_id = ?', ['page-1']) as $fassung) {
$this->db->update('revision', ['document' => str_replace('color: red;', $alt, $fassung['document'])], ['id' => $fassung['id']]);
}
$antwort = $this->request('/');
self::assertSame(200, $antwort->getStatusCode());
self::assertStringContainsString('Bleibt lesbar', $antwort->getContent());
self::assertStringNotContainsString('~ p', $antwort->getContent(), 'Das abgelehnte CSS wird nicht ausgegeben.');
$entwurf = $this->pages->findForEditor('page-1');
self::assertSame($alt, end($entwurf['document']['blocks'])['css'], 'Im Editor steht es noch da und lässt sich korrigieren.');
// Speichern verlangt weiter gültiges CSS.
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $entwurf['document'], $entwurf['version']);
}
public function testSettingsAndThemeRefuseAWriteFromAnOutdatedForm(): void
{
// Zwei Admins haben dasselbe Formular offen. Der zweite überschrieb bisher still, was der
// erste gespeichert hatte. Jetzt trägt das Formular eine Kennung des geladenen Stands.
$werte = ['legalName' => 'Erste GmbH'];
$geladen = $this->site->stamp();
$this->site->save($werte, $geladen);
self::assertNotSame($geladen, $this->site->stamp());
try {
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $geladen);
self::fail('Der veraltete Stand muss abgelehnt werden.');
} catch (StaleWrite) {
}
self::assertSame('Erste GmbH', $this->site->load()['legalName']);
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $this->site->stamp());
self::assertSame('Zweite GmbH', $this->site->load()['legalName']);
$theme = new Theme($this->db);
$stand = $theme->load();
$geladen = $theme->stamp();
$theme->save($stand['colors'], $stand['fonts'], [...$stand['base'], 'linkUnderline' => null], null, $geladen);
try {
$theme->save($stand['colors'], $stand['fonts'], $stand['base'], null, $geladen);
self::fail('Der veraltete Stand muss abgelehnt werden.');
} catch (StaleWrite) {
}
self::assertFalse($theme->load()['base']['linkUnderline'], 'Die erste Änderung bleibt.');
}
public function testAFormInTheFooterCanBeSentFromAnArchive(): void
{
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
+24
View File
@@ -196,6 +196,30 @@ final class BackupTest extends TestCase
self::assertSame([$names[1], $names[0]], $removed);
}
public function testADamagedBackupNeitherCountsAsIntactNorPushesOutACompleteOne(): void
{
$alt = $this->backup->create('Test', new \DateTimeImmutable('2026-09-10 10:00:00', new \DateTimeZone('UTC')));
$neu = $this->backup->create('Test', new \DateTimeImmutable('2026-09-11 10:00:00', new \DateTimeZone('UTC')));
// Fehlt eine der gezählten Mediendateien, ist die Sicherung nicht mehr unversehrt.
unlink($neu['path'].'/medien/bild-480.webp');
// Ein abgebrochener Lauf von früher: ein Ordner ohne Verzeichnis, der neueste von allen.
$halb = $this->backup->directory().'/sicherung-20260912-100000';
mkdir($halb);
touch($halb, time() - 7200);
// Und einer von eben - das kann eine Sicherung sein, die gerade geschrieben wird.
$laufend = $this->backup->directory().'/sicherung-20260913-100000';
mkdir($laufend);
touch($neu['path'], time() - 7200);
$zustand = array_column($this->backup->all(), 'intact', 'name');
self::assertSame([basename($laufend) => false, basename($halb) => false, $neu['name'] => false, $alt['name'] => true], $zustand);
// Vorher zählten die beschädigten als die neuesten, und die vollständige flog raus.
$entfernt = $this->backup->rotate(1);
self::assertEqualsCanonicalizing([basename($halb), $neu['name']], $entfernt);
self::assertSame([basename($laufend), $alt['name']], array_column($this->backup->all(), 'name'));
}
public function testTwoBackupsInTheSameSecondGetTwoDirectories(): void
{
// This is not academic: a restore takes a safety copy, and that lands in the same second
+111 -5
View File
@@ -1757,7 +1757,7 @@ final class CmsTest extends TestCase
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
self::assertStringContainsString('/720', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
// einem ungedämpften Bild.
@@ -1770,7 +1770,7 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
self::assertStringContainsString('/720', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
}
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
@@ -3828,6 +3828,97 @@ display: flex', '#b1{display:flex;color:red}'];
(new DocumentValidator())->validate($document);
}
public function testAnImageServedOnceLandsWhereTheWebServerDeliversItWithoutPhp(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
$image = imagecreatetruecolor(600, 400);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
@unlink($file);
$abbild = $this->path.'-abbild';
$ziel = $abbild.'/'.$item['id'].'/480.webp';
$quelle = $this->path.'-media/'.$item['id'].'-480.webp';
// Ohne Abbild (so laufen alle anderen Tests) bleibt es bei der Auslieferung über PHP.
self::assertSame(200, $this->request('/media/'.$item['id'].'/480')->getStatusCode());
self::assertDirectoryDoesNotExist($abbild);
$_SERVER['MEDIA_MIRROR_DIRECTORY'] = $_ENV['MEDIA_MIRROR_DIRECTORY'] = $abbild;
try {
$antwort = $this->request('/media/'.$item['id'].'/480');
self::assertSame(200, $antwort->getStatusCode());
self::assertFileEquals($quelle, $ziel, 'Die Variante liegt unter der Adresse, die public/.htaccess prüft.');
// Eine Breite, die es nicht gibt, legt nichts an.
self::assertSame(404, $this->request('/media/'.$item['id'].'/999')->getStatusCode());
self::assertFileDoesNotExist($abbild.'/'.$item['id'].'/999.webp');
} finally {
unset($_SERVER['MEDIA_MIRROR_DIRECTORY'], $_ENV['MEDIA_MIRROR_DIRECTORY']);
}
// Verschwindet das Bild aus dem Medienordner (Paketimport), räumt das Abbild es weg -
// sonst lieferte der Webserver weiter aus, was die Anwendung nicht mehr kennt.
$mirror = new \App\Content\MediaMirror($abbild, $this->path.'-media');
self::assertSame(0, $mirror->prune());
unlink($quelle);
file_put_contents($abbild.'/'.$item['id'].'/fremd.txt', 'gehört nicht hierher');
self::assertSame(2, $mirror->prune());
self::assertDirectoryDoesNotExist($abbild.'/'.$item['id']);
@rmdir($abbild);
self::assertSame(0, (new \App\Content\MediaMirror('', $this->path.'-media'))->prune());
}
public function testAnOlderImageGetsTheNewWidthsAndStoredPagesUseThem(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
$image = imagecreatetruecolor(1700, 1000);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'alt.jpg', test: true));
// So sah ein Bild vor 1.42.0 aus: ohne 720 und 1280, in der Mediathek wie in der Seite.
$alt = array_values(array_filter($item['variants'], static fn (array $v): bool => !in_array($v['width'], [720, 1280], true)));
foreach ([720, 1280] as $width) {
unlink($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
}
$this->db->update('media', ['variants' => json_encode($alt)], ['id' => $item['id']]);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'] = [['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
['id' => 'bild', 'type' => 'image', 'props' => ['mediaId' => $item['id'], 'src' => '/media/'.$item['id'].'/1700', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 1700, 'height' => 1000, 'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $alt)]],
]]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
self::assertStringNotContainsString('720w', $this->request('/')->getContent());
$media = new MediaLibrary($this->db, $this->path.'-media');
self::assertSame(['done' => 1, 'left' => 0], $media->fillVariants());
$neu = $media->find($item['id']);
self::assertSame([480, 720, 960, 1280, 1700], array_column($neu['variants'], 'width'));
self::assertSame($item['version'], $neu['version'], 'Die Schreibversion bleibt: ein offenes Formular zum Bild scheitert daran nicht.');
foreach ([720 => 424, 1280 => 753] as $width => $height) {
$size = getimagesize($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
self::assertSame([$width, $height, 'image/webp'], [$size[0], $size[1], $size['mime']]);
}
// Ein zweiter Lauf hat nichts mehr zu tun.
self::assertSame(['done' => 0, 'left' => 0], $media->fillVariants());
// Die gespeicherte Seite nennt die neuen Größen, ohne dass ihr Dokument angefasst wurde -
// im Bild wie in seiner Vorabladung.
$html = $this->request('/')->getContent();
self::assertMatchesRegularExpression('#<link rel="preload" as="image"[^>]+imagesrcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
self::assertMatchesRegularExpression('#<img[^>]+srcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
self::assertStringNotContainsString('720', (string) $this->db->fetchOne('SELECT document FROM revision ORDER BY seq DESC LIMIT 1'));
} finally {
ini_set('memory_limit', $limit);
@unlink($file);
}
}
public function testAnUploadBecomesWebpInSeveralWidths(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
@@ -3845,7 +3936,7 @@ display: flex', '#b1{display:flex;color:red}'];
$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'));
self::assertSame([480, 720, 960, 1280, 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.');
@@ -3886,7 +3977,7 @@ display: flex', '#b1{display:flex;color:red}'];
// 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);
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/720")}}', $css);
} finally {
@unlink($file);
}
@@ -3983,7 +4074,7 @@ display: flex', '#b1{display:flex;color:red}'];
array_column($treffer, 2),
);
self::assertSame(
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/720'],
array_column($treffer, 1),
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
);
@@ -4003,6 +4094,21 @@ display: flex', '#b1{display:flex;color:red}'];
$mitBild,
$sparsam,
));
// Das Telefon ohne Hintergrund, das Tablet ausgeblendet: vorab geholt wird nur, was
// die Regel des jeweiligen Geräts auch zeigt. Vorher zählte allein der Desktop-Wert.
$mit['schemaVersion'] = max(22, $mit['schemaVersion']);
$mit['blocks'][0]['style']['mobile']['backgroundImage'] = 'none';
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
self::assertSame(['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)'], array_column($treffer, 2), 'Das Telefon lädt kein Bild vor, das es nicht zeigt.');
$mit['blocks'][0]['layout']['visibility'] = 'hide-tablet';
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
self::assertSame(['(min-width:1025px)'], array_column($treffer, 2));
} finally {
@unlink($datei);
ini_set('memory_limit', (string) $limit);
+1 -1
View File
@@ -954,7 +954,7 @@ final class EditorSecurityTest extends WebTestCase
$item = json_decode($client->getResponse()->getContent(), true)['item'];
// 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'));
self::assertSame([480, 720, 960, 1280, 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']);
+10
View File
@@ -90,6 +90,16 @@ final class SitePackageTest extends TestCase
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance');
}
public function testAnImportLeavesAMaintenanceStateThatWasAlreadyOn(): void
{
// Wer die Wartung vorher von Hand eingeschaltet hat, will sie nach dem Import noch haben.
file_put_contents($this->sites['ziel']['root'].'/var/maintenance', "an\n");
$result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export());
self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
self::assertFileExists($this->sites['ziel']['root'].'/var/maintenance');
self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
}
public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void
{
// Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert.
+1 -1
View File
@@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
self::assertSame(7, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
}
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void