Compare commits

..
27 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
kantarciandClaude Fable 5.2 3386cbb767 Note the analysis and 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:15:49 +01:00
kantarciandClaude Fable 5.2 f3798cc102 Release 1.40.1
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00
kantarciandClaude Fable 5.2 d464c82ac8 Fix lost form submissions on archives, the unlocked update finish and six more
Update Symfony to 7.4.23 (12 advisories in 8 packages).

- A form can be sent from /beitraege, /kategorie/... and /schlagwort/...;
  it answered 404 before anything was stored or mailed.
- Booleans reach the database as integers, so MySQL in strict mode
  accepts form submissions, task switches and plugin states.
- A stepped form with a required multiple choice shows its message
  instead of stopping on a TypeError.
- The second update step runs under the update lock; a concurrent
  finish no longer rolls a successful update back.
- Accordion entries and gallery images can be selected from the
  editor panel again.
- Category and tag names are escaped in the archive heading.
- Restoring a published page from the bin needs the publisher role.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00
kantarciandClaude Opus 5.5 441e629e80 Note the 1.40.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 Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:10:07 +01:00
kantarciandClaude Opus 5.5 492bb96fff Release 1.40.0
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 Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:08:32 +01:00
kantarciandClaude Opus 5.5 9e3862093e Add the Becker Just In Time site build
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
Seed, asset and reset scripts for Becker - Just In Time e. Kfm.
(Wiernsheim): logo cut from the PDF raster, logo-free photos generated
with ChatGPT, before/after slider, service pages, contact form, legal
pages. Launch configs on ports 8116/8117, Manrope 800 and Montserrat
700/800 fetched locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:03:16 +01:00
kantarciandClaude Opus 5.5 c90c03084b Add a filled-outline house icon to the icon set
"house" is a closed house shape with the door cut out, next to the
existing line-drawn "home". Written through scripts/build-icons.mjs
into the Twig template and the validator allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:03:15 +01:00
kantarciandClaude Opus 5.5 76e179b077 Note the 1.39.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 Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:58 +01:00
kantarciandClaude Opus 5.5 b60e599dda Release 1.39.1
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 Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 791c265f42 Add the listings page to the Luximmo site build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 78083fac6a Use own SVG icons instead of text glyphs on the website
Phones turn glyphs such as arrows and crosses into emoji. The viewer, scroll button, video toggle, menus, breadcrumbs, pagers and quote stars now draw SVG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 b9740e6c1b Note the 1.39.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 Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:19:02 +01:00
kantarciandClaude Opus 5.5 2adcaab5c2 Release 1.39.0
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 Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:18:17 +01:00
kantarciandClaude Opus 5.5 9825bac3b3 Let plugins run their own PHP code
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
A plugin can now bring a plugin.php that registers hooks, scheduled tasks, its own tables, public pages and admin screens. Failures stay contained to the plugin; a form on a plugin page can be submitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:56:14 +01:00
kantarciandClaude Opus 5.5 b61f958ca1 Note the 1.38.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:04:21 +01:00
kantarciandClaude Opus 5.5 b943af5fe7 Release 1.38.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:02:38 +01:00
kantarciandClaude Opus 5.5 9add795d6b Add the Luximmo site build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:02:38 +01:00
kantarciandClaude Opus 5.5 f2c04b311f Add business type, location and logo to the structured data
Three optional settings extend LocalBusiness: a subtype from a fixed list, coordinates as a pair, and a logo from the media library. Without them the output stays as it was, and stored settings need no migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:02:38 +01:00
kantarciandClaude Opus 5.5 d75c295f59 Note the 1.37.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:48:10 +01:00
109 changed files with 5265 additions and 326 deletions

No files matched your search

+40
View File
@@ -229,6 +229,46 @@
], ],
"port": 8107 "port": 8107
}, },
{
"name": "becker",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8116'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8116 -t public bin/dev-router.php"
],
"port": 8116
},
{
"name": "becker-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8117'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8117 -t public bin/dev-router.php"
],
"port": 8117
},
{
"name": "luximmo",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8108'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8108 -t public bin/dev-router.php"
],
"port": 8108
},
{
"name": "luximmo-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8109'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8109 -t public bin/dev-router.php"
],
"port": 8109
},
{ {
"name": "zentrale", "name": "zentrale",
"runtimeExecutable": "C:/php84/php.exe", "runtimeExecutable": "C:/php84/php.exe",
+2
View File
@@ -18,6 +18,8 @@
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere # 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. # Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
/public/assets/build/ /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 (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
/zentrale/config.php /zentrale/config.php
+4
View File
@@ -407,6 +407,10 @@ Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `va
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-Skripts. Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-Skripts.
### Luximmo Hausverwaltung (Gaggenau)
Der Nachbau des Entwurfs `luximmo-website.zip` liegt in `var/luximmo.db` mit Medien in `var/luximmo-media`; die Startkonfiguration `luximmo` nutzt Port 8108, `luximmo-prod` denselben Bestand im Produktionsmodus auf Port 8109. `sh scripts/reset-luximmo.sh [assets-Ordner des Pakets]` holt ein Standbild aus dem Hero-Video (`scripts/build-luximmo-poster.mjs`, Playwright), bereitet Logo, Seitensymbol und Fotos vor (`scripts/build-luximmo-assets.php`; ohne Argument nimmt es `var/luximmo-assets`), baut mit `scripts/seed-luximmo.php` Startseite (Hintergrundvideo im Hero), Verwaltungsanfrage, Impressum, Datenschutz, 404, Menü und die Formulare `kontakt`, `verwaltungsanfrage` und `bewertung` (je drei Schritte mit Bildkarten und Zusammenfassung; Bewertung auf `/immobilienbewertung`) auf, holt Instrument Sans auf den eigenen Server und schreibt `var/luximmo-paket.zip`. Formulare gehen an info@lux-immo.net (abweichend per `LUXIMMO_MAIL`). Passwort für „umut“ in `var/luximmo-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8109 CMS_PATHS=/,/verwaltungsanfrage,/impressum CMS_AUDIT_DIR=reports/lighthouse-luximmo node scripts/audit.mjs`.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`. Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`. Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
+1 -1
View File
@@ -1 +1 @@
1.37.0 1.42.0
+11
View File
@@ -9,6 +9,17 @@
* what public/.htaccess does. This script reproduces that behaviour for local development. * what public/.htaccess does. This script reproduces that behaviour for local development.
*/ */
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $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; $file = __DIR__.'/../public'.$path;
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) { if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets, // Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
Generated
+151 -151
View File
@@ -748,16 +748,16 @@
}, },
{ {
"name": "symfony/cache", "name": "symfony/cache",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/cache.git", "url": "https://github.com/symfony/cache.git",
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7" "reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7", "url": "https://api.github.com/repos/symfony/cache/zipball/bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7", "reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -827,7 +827,7 @@
"psr6" "psr6"
], ],
"support": { "support": {
"source": "https://github.com/symfony/cache/tree/v7.4.22" "source": "https://github.com/symfony/cache/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -847,20 +847,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-28T10:54:34+00:00" "time": "2026-11-01T22:26:51+00:00"
}, },
{ {
"name": "symfony/cache-contracts", "name": "symfony/cache-contracts",
"version": "v3.7.1", "version": "v3.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/cache-contracts.git", "url": "https://github.com/symfony/cache-contracts.git",
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" "reference": "af26a91232085b7edd4e04be62f75e7c25159ece"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/af26a91232085b7edd4e04be62f75e7c25159ece",
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "reference": "af26a91232085b7edd4e04be62f75e7c25159ece",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -874,7 +874,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.7-dev" "dev-main": "3.8-dev"
} }
}, },
"autoload": { "autoload": {
@@ -907,7 +907,7 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" "source": "https://github.com/symfony/cache-contracts/tree/v3.8.1"
}, },
"funding": [ "funding": [
{ {
@@ -927,7 +927,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-09-26T07:05:14+00:00"
}, },
{ {
"name": "symfony/clock", "name": "symfony/clock",
@@ -1009,16 +1009,16 @@
}, },
{ {
"name": "symfony/config", "name": "symfony/config",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/config.git", "url": "https://github.com/symfony/config.git",
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f" "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/d7f9d7495992429d916aea42d1a4d76a12dfa29f", "url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f", "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1064,7 +1064,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/config/tree/v7.4.22" "source": "https://github.com/symfony/config/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1084,20 +1084,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-25T14:40:16+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" "reference": "81957c2440c05c962fe8291f318de6e85f8db330"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", "url": "https://api.github.com/repos/symfony/console/zipball/81957c2440c05c962fe8291f318de6e85f8db330",
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", "reference": "81957c2440c05c962fe8291f318de6e85f8db330",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1162,7 +1162,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.22" "source": "https://github.com/symfony/console/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1182,20 +1182,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-28T11:08:27+00:00" "time": "2026-11-03T21:47:07+00:00"
}, },
{ {
"name": "symfony/dependency-injection", "name": "symfony/dependency-injection",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/dependency-injection.git", "url": "https://github.com/symfony/dependency-injection.git",
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd" "reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f98a1b0625d5608a723481fa104b5473e6b18ebd", "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd", "reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1246,7 +1246,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application", "description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.22" "source": "https://github.com/symfony/dependency-injection/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1266,20 +1266,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-25T14:40:16+00:00" "time": "2026-11-01T17:38:00+00:00"
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.7.1", "version": "v3.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git", "url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1292,7 +1292,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.7-dev" "dev-main": "3.8-dev"
} }
}, },
"autoload": { "autoload": {
@@ -1317,7 +1317,7 @@
"description": "A generic function and convention to trigger deprecation notices", "description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1"
}, },
"funding": [ "funding": [
{ {
@@ -1337,7 +1337,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-09-16T14:15:10+00:00"
}, },
{ {
"name": "symfony/dotenv", "name": "symfony/dotenv",
@@ -1586,16 +1586,16 @@
}, },
{ {
"name": "symfony/event-dispatcher-contracts", "name": "symfony/event-dispatcher-contracts",
"version": "v3.7.1", "version": "v3.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git", "url": "https://github.com/symfony/event-dispatcher-contracts.git",
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" "reference": "658e29751424da3ef4758c60f769e59d1b2981eb"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb",
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "reference": "658e29751424da3ef4758c60f769e59d1b2981eb",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1609,7 +1609,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.7-dev" "dev-main": "3.8-dev"
} }
}, },
"autoload": { "autoload": {
@@ -1642,7 +1642,7 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.8.1"
}, },
"funding": [ "funding": [
{ {
@@ -1662,20 +1662,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-09-16T14:15:10+00:00"
}, },
{ {
"name": "symfony/filesystem", "name": "symfony/filesystem",
"version": "v7.4.21", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/filesystem.git", "url": "https://github.com/symfony/filesystem.git",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", "url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1712,7 +1712,7 @@
"description": "Provides basic utilities for the filesystem", "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.21" "source": "https://github.com/symfony/filesystem/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1732,20 +1732,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-06T20:05:19+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/finder.git", "url": "https://github.com/symfony/finder.git",
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" "reference": "c723f990aefe8c1053b18374faf4231eba84f518"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", "url": "https://api.github.com/repos/symfony/finder/zipball/c723f990aefe8c1053b18374faf4231eba84f518",
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", "reference": "c723f990aefe8c1053b18374faf4231eba84f518",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1780,7 +1780,7 @@
"description": "Finds files and directories via an intuitive fluent interface", "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/finder/tree/v7.4.22" "source": "https://github.com/symfony/finder/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1800,20 +1800,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-19T16:56:54+00:00" "time": "2026-11-02T10:56:49+00:00"
}, },
{ {
"name": "symfony/framework-bundle", "name": "symfony/framework-bundle",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/framework-bundle.git", "url": "https://github.com/symfony/framework-bundle.git",
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c" "reference": "6a749f2c37cb9def53793633ec72958aaad4b359"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/17d23a62561b493b85486c9ae8c2752f36303b2c", "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a749f2c37cb9def53793633ec72958aaad4b359",
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c", "reference": "6a749f2c37cb9def53793633ec72958aaad4b359",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1828,7 +1828,7 @@
"symfony/event-dispatcher": "^6.4|^7.0|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^7.1|^8.0", "symfony/filesystem": "^7.1|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.4|^8.0", "symfony/http-foundation": "^7.4.23|^8.1.11",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0",
"symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php85": "^1.32", "symfony/polyfill-php85": "^1.32",
@@ -1937,7 +1937,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.22" "source": "https://github.com/symfony/framework-bundle/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -1957,20 +1957,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-26T21:52:50+00:00" "time": "2026-11-04T07:46:24+00:00"
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-foundation.git", "url": "https://github.com/symfony/http-foundation.git",
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a" "reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a", "reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2019,7 +2019,7 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.22" "source": "https://github.com/symfony/http-foundation/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -2039,20 +2039,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-27T13:26:03+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-kernel.git", "url": "https://github.com/symfony/http-kernel.git",
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d" "reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d", "url": "https://api.github.com/repos/symfony/http-kernel/zipball/94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d", "reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2138,7 +2138,7 @@
"description": "Provides a structured process for converting a Request into a Response", "description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.22" "source": "https://github.com/symfony/http-kernel/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -2158,7 +2158,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-28T18:18:28+00:00" "time": "2026-11-04T11:26:18+00:00"
}, },
{ {
"name": "symfony/mailer", "name": "symfony/mailer",
@@ -3064,16 +3064,16 @@
}, },
{ {
"name": "symfony/property-access", "name": "symfony/property-access",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/property-access.git", "url": "https://github.com/symfony/property-access.git",
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a" "reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/property-access/zipball/b45cfcfe0abdbba37350645dae148be29b94f25a", "url": "https://api.github.com/repos/symfony/property-access/zipball/4dce45112d7a31d530d2e0e69143fc88eaa1521f",
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a", "reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3121,7 +3121,7 @@
"reflection" "reflection"
], ],
"support": { "support": {
"source": "https://github.com/symfony/property-access/tree/v7.4.22" "source": "https://github.com/symfony/property-access/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3141,20 +3141,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-26T21:52:50+00:00" "time": "2026-11-01T19:17:22+00:00"
}, },
{ {
"name": "symfony/property-info", "name": "symfony/property-info",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/property-info.git", "url": "https://github.com/symfony/property-info.git",
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5" "reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/property-info/zipball/25c1b823ee4e0995e57e49bc0160c43ea199a7b5", "url": "https://api.github.com/repos/symfony/property-info/zipball/1eeaa666bcd1864b883b98cad9e5188064ddac26",
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5", "reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3211,7 +3211,7 @@
"validator" "validator"
], ],
"support": { "support": {
"source": "https://github.com/symfony/property-info/tree/v7.4.22" "source": "https://github.com/symfony/property-info/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3231,20 +3231,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-25T14:40:16+00:00" "time": "2026-11-02T10:56:49+00:00"
}, },
{ {
"name": "symfony/rate-limiter", "name": "symfony/rate-limiter",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/rate-limiter.git", "url": "https://github.com/symfony/rate-limiter.git",
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6" "reference": "a65b8280000598b80b4456004280a56fdad011b3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/97a013c93ce39c70ace5316499c0d8aaec43a7f6", "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/a65b8280000598b80b4456004280a56fdad011b3",
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6", "reference": "a65b8280000598b80b4456004280a56fdad011b3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3285,7 +3285,7 @@
"rate-limiter" "rate-limiter"
], ],
"support": { "support": {
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.22" "source": "https://github.com/symfony/rate-limiter/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3305,20 +3305,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-20T19:25:08+00:00" "time": "2026-11-01T19:17:22+00:00"
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/routing.git", "url": "https://github.com/symfony/routing.git",
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a" "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", "url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73",
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3370,7 +3370,7 @@
"url" "url"
], ],
"support": { "support": {
"source": "https://github.com/symfony/routing/tree/v7.4.22" "source": "https://github.com/symfony/routing/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3390,20 +3390,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-26T21:52:50+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/security-bundle", "name": "symfony/security-bundle",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/security-bundle.git", "url": "https://github.com/symfony/security-bundle.git",
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7" "reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/0323d198b93b3ec4fd0e31a5e84516c2b28261d7", "url": "https://api.github.com/repos/symfony/security-bundle/zipball/1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7", "reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3415,7 +3415,7 @@
"symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0", "symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0",
"symfony/deprecation-contracts": "^2.5|^3", "symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^6.4.50|^7.4.23|^8.1.11",
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
"symfony/password-hasher": "^6.4|^7.0|^8.0", "symfony/password-hasher": "^6.4|^7.0|^8.0",
"symfony/security-core": "^7.4.22|^8.1.10", "symfony/security-core": "^7.4.22|^8.1.10",
@@ -3483,7 +3483,7 @@
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/security-bundle/tree/v7.4.22" "source": "https://github.com/symfony/security-bundle/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3503,20 +3503,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-27T13:52:57+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/security-core", "name": "symfony/security-core",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/security-core.git", "url": "https://github.com/symfony/security-core.git",
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674" "reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4efce2a1e6c3d686bf0ec9a098e425d53dc3e674", "url": "https://api.github.com/repos/symfony/security-core/zipball/c93b2194c0255496c2c0ac999821fa7f5296c7cc",
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674", "reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3575,7 +3575,7 @@
"description": "Symfony Security Component - Core Library", "description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/security-core/tree/v7.4.22" "source": "https://github.com/symfony/security-core/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3595,7 +3595,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-25T14:40:16+00:00" "time": "2026-11-01T19:17:22+00:00"
}, },
{ {
"name": "symfony/security-csrf", "name": "symfony/security-csrf",
@@ -3673,16 +3673,16 @@
}, },
{ {
"name": "symfony/security-http", "name": "symfony/security-http",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/security-http.git", "url": "https://github.com/symfony/security-http.git",
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29" "reference": "ea0623a02764025268bd928810f012767ea1965a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/security-http/zipball/0020c7563c188bade070cf4bc83be3ee75ceab29", "url": "https://api.github.com/repos/symfony/security-http/zipball/ea0623a02764025268bd928810f012767ea1965a",
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29", "reference": "ea0623a02764025268bd928810f012767ea1965a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3742,7 +3742,7 @@
"description": "Symfony Security Component - HTTP Integration", "description": "Symfony Security Component - HTTP Integration",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/security-http/tree/v7.4.22" "source": "https://github.com/symfony/security-http/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -3762,20 +3762,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-28T10:54:34+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "symfony/service-contracts", "name": "symfony/service-contracts",
"version": "v3.7.3", "version": "v3.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/service-contracts.git", "url": "https://github.com/symfony/service-contracts.git",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3793,7 +3793,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.7-dev" "dev-main": "3.8-dev"
} }
}, },
"autoload": { "autoload": {
@@ -3829,7 +3829,7 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3" "source": "https://github.com/symfony/service-contracts/tree/v3.8.1"
}, },
"funding": [ "funding": [
{ {
@@ -3849,7 +3849,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-27T15:39:01+00:00" "time": "2026-10-20T17:38:25+00:00"
}, },
{ {
"name": "symfony/stopwatch", "name": "symfony/stopwatch",
@@ -4010,16 +4010,16 @@
}, },
{ {
"name": "symfony/translation-contracts", "name": "symfony/translation-contracts",
"version": "v3.7.1", "version": "v3.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/translation-contracts.git", "url": "https://github.com/symfony/translation-contracts.git",
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" "reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0a3ae917402632fcba2aeddae9c98332f3d7dd05",
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4032,7 +4032,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.7-dev" "dev-main": "3.8-dev"
} }
}, },
"autoload": { "autoload": {
@@ -4068,7 +4068,7 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" "source": "https://github.com/symfony/translation-contracts/tree/v3.8.1"
}, },
"funding": [ "funding": [
{ {
@@ -4088,7 +4088,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-10-03T08:17:19+00:00"
}, },
{ {
"name": "symfony/twig-bridge", "name": "symfony/twig-bridge",
@@ -4297,16 +4297,16 @@
}, },
{ {
"name": "symfony/type-info", "name": "symfony/type-info",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/type-info.git", "url": "https://github.com/symfony/type-info.git",
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0" "reference": "eca5fdb294539268ad3245b2214f32f24b337e09"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/type-info/zipball/f69ab198af73e1aac485c0ff5aa066d20c14adf0", "url": "https://api.github.com/repos/symfony/type-info/zipball/eca5fdb294539268ad3245b2214f32f24b337e09",
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0", "reference": "eca5fdb294539268ad3245b2214f32f24b337e09",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4356,7 +4356,7 @@
"type" "type"
], ],
"support": { "support": {
"source": "https://github.com/symfony/type-info/tree/v7.4.22" "source": "https://github.com/symfony/type-info/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -4376,7 +4376,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-24T08:22:23+00:00" "time": "2026-11-02T09:31:16+00:00"
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
@@ -4548,16 +4548,16 @@
}, },
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/yaml.git", "url": "https://github.com/symfony/yaml.git",
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b" "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/1ed6afa13d48c579c51cbf32e38f50686313497b", "url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec",
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b", "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4600,7 +4600,7 @@
"description": "Loads and dumps YAML files", "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.22" "source": "https://github.com/symfony/yaml/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -4620,7 +4620,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-18T14:39:27+00:00" "time": "2026-11-04T08:27:22+00:00"
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
@@ -7578,16 +7578,16 @@
}, },
{ {
"name": "symfony/browser-kit", "name": "symfony/browser-kit",
"version": "v7.4.22", "version": "v7.4.23",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/browser-kit.git", "url": "https://github.com/symfony/browser-kit.git",
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed" "reference": "a0d19ca90c1fcce29d3337decb761b5adb808965"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/018a7c9b63d9ed402762a2d9f59238207928a2ed", "url": "https://api.github.com/repos/symfony/browser-kit/zipball/a0d19ca90c1fcce29d3337decb761b5adb808965",
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed", "reference": "a0d19ca90c1fcce29d3337decb761b5adb808965",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7628,7 +7628,7 @@
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/browser-kit/tree/v7.4.22" "source": "https://github.com/symfony/browser-kit/tree/v7.4.23"
}, },
"funding": [ "funding": [
{ {
@@ -7648,7 +7648,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-10-20T19:25:08+00:00" "time": "2026-11-01T07:00:22+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
+11 -1
View File
@@ -10,6 +10,9 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls. # das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts' kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets' 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.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default' kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest. # Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
@@ -30,7 +33,7 @@ services:
tags: ['kant.task'] tags: ['kant.task']
App\: App\:
resource: '../src/' resource: '../src/'
exclude: '../src/{Kernel.php,Domain/}' exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}'
Doctrine\DBAL\Connection: Doctrine\DBAL\Connection:
factory: ['App\Infrastructure\ConnectionFactory', 'create'] factory: ['App\Infrastructure\ConnectionFactory', 'create']
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter'] arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
@@ -69,6 +72,10 @@ services:
App\Content\StyleBundle: App\Content\StyleBundle:
arguments: arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%' $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 # Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
# schreiben. # schreiben.
App\Ops\MailCheck: App\Ops\MailCheck:
@@ -156,6 +163,7 @@ services:
arguments: arguments:
$tasks: !tagged_iterator kant.task $tasks: !tagged_iterator kant.task
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
$plugins: '@App\Plugin\PluginRuntime'
App\Ops\Zentrale: App\Ops\Zentrale:
arguments: arguments:
$root: '%kernel.project_dir%' $root: '%kernel.project_dir%'
@@ -208,5 +216,7 @@ services:
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird. # Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test: when@test:
parameters: 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_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587' kant.mail_dsn: 'smtp://cms.test:587'
+33 -1
View File
@@ -1,6 +1,38 @@
# Architekturentscheidungen # 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)
**Kontext:** Umut will einen ImmoScout-Abgleich für DEEG, ausdrücklich als Plugin und nicht im Kern: „Ansonsten musst du Kern so erweitern, dass Plugins das können“, „wie WordPress“. Das hebt die Grundentscheidung aus ADR 103 auf, dass ein Plugin nur Manifest plus Twig ist; dort war sie mit dem fehlenden Bedarf und der Stabilität begründet. Eigene Daten und Arbeit im Takt gehen ohne Servercode nicht.
**Entscheidung:** `"main"` im Manifest nennt eine PHP-Datei, die eine Funktion zurückgibt. `PluginRuntime` lädt sie für aktive Plugins, `PluginApi` nimmt Hooks, Aufgaben (im vorhandenen `Scheduler`), Seiten (durch `renderPage`, also mit Kopf, Fuß und Design), Twig-Daten und Verwaltungsbildschirme entgegen. Eigene Tabellen legt das Plugin in `onInstall` an, Präfix `plugin_<name>_`; dadurch braucht der Kern keine Plugin-Migrationen. Reine Twig-Plugins bleiben unverändert.
**Folgen:** Plugin-Code läuft mit vollen Rechten, das steht in der Verwaltung und wird nicht als Sandkasten bezeichnet. Anders als bei WordPress gibt es bewusst keinen ZIP-Upload in der Verwaltung: Wer PHP auf den Server legt, tut das per FTP, ein gestohlenes Adminkonto wird so nicht zur Codeausführung. Ausnahmen grenzt die Runtime auf das Plugin ein, einen harten PHP-Abbruch nicht. Der Kern löst vorerst nur `sitemap.entries` aus; weitere Hooks kommen mit dem ersten Bedarf.
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
**Entscheidung:** Drei Einstellungen unter „Einstellungen“: `businessType` aus einer festen Liste von LocalBusiness-Unterarten, `latitude`/`longitude` als Paar, `organisationLogoMediaId` aus der Mediathek. Das Logo ist bewusst getrennt vom sichtbaren Logo im Kopfbereich: Das kann eingebettetes SVG sein und hat dann keine eigene Adresse. Die Liste ist fest, damit kein Tippfehler einen ungültigen Typ erzeugt.
**Folgen:** Keine Migration: Die Werte liegen im JSON der Einstellungen, fehlende Schlüssel fallen auf die Standardwerte, ausgegeben wird dann genau das Bisherige. Typ und Koordinaten gelten nur für ein lokales Unternehmen mit Anschrift, das Logo auch für eine Organisation. Eine Installation älter als diese Fassung ignoriert die Werte aus einem Inhaltspaket und gibt sie nicht aus.
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026) ## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
+46 -3
View File
@@ -5,9 +5,9 @@ Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört de
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins** Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`. (Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen. `plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127).
## Der Ordner ## Der Ordner
@@ -121,6 +121,49 @@ Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der W
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
eingestellt wurde, geht durch ein Plugin verloren (ADR 105). eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
## Eigener PHP-Code (`plugin.php`)
`"main": "plugin.php"` im Manifest lädt die Datei, solange das Plugin eingeschaltet ist. Sie gibt
eine Funktion zurück und meldet sich dort beim Kern an:
```php
<?php
return static function (App\Plugin\PluginApi $api): void {
$tabelle = $api->table('objekt'); // plugin_<name>_objekt
$api->onInstall(fn () => $api->db()->executeStatement("CREATE TABLE IF NOT EXISTS $tabelle (id VARCHAR(20) PRIMARY KEY)"));
$api->addTask('abgleich', 'Abgleich', 'Was ausbleibt, wenn sie nicht läuft.', 3600, fn (): string => 'Bericht');
$api->addPage('/objekte/{slug}', fn (array $p): ?array => ['title' => 'Titel', 'description' => 'Text', 'blocks' => []]);
$api->addData('liste', fn (array $argumente): array => []);
$api->addFilter('sitemap.entries', fn (array $e, string $origin): array => $e);
$api->addAdminPage('uebersicht', 'Übersicht', 'admin/uebersicht.html.twig', fn (): array => []);
$api->addAdminAction('jetzt', 'Jetzt ausführen', 'abgleich'); // Aufgabenschlüssel oder Callback
};
```
| Methode | Zweck |
|---|---|
| `onInstall`, `onDeactivate` | Beim Einschalten und bei neuer Fassung; beim Ausschalten. Muss wiederholbar sein. Tabellen bleiben beim Ausschalten stehen. |
| `addAction`, `addFilter` | Hooks wie bei WordPress, kleinere Priorität zuerst. |
| `addTask` | Aufgabe im Plan des Kerns (Bildschirm „Aufgaben“); Fehler werfen, Rückgabe ist der Bericht. |
| `addPage` | Öffentliche Seite im Rahmen der Website. Gibt `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image` zurück oder `null` für 404. Eine veröffentlichte Seite geht vor. |
| `addData` | Werte für Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`. |
| `addAdminPage`, `addAdminAction` | Bildschirm und Knopf der Verwaltung, nur für Administratoren, Knöpfe mit CSRF-Schutz. |
| `settings`, `db`, `table`, `media`, `fetch`, `storeImage`, `directory` | Einstellungen, Datenbank, Mediathek, HTTPS-Abruf, Bild ablegen. |
Der Kern ruft bisher diese Filter auf: `sitemap.entries` (`$entries`, `$origin`) und `form.context`
(`$extra`, `$form`, `$path`: zusätzliche Angaben `Name => Text` für eine eingehende Anfrage).
Weitere Hooks kommen in den
Kern, wenn ein Plugin sie braucht.
Die SQL-Anweisungen müssen auf SQLite und MySQL laufen. Klassen und Funktionen gehören in einen
eigenen Namensraum. Plugin-Tabellen gehören nicht zum Inhaltspaket; bei SQLite sind sie in jeder
Sicherung, weil die ganze Datei gesichert wird.
**Rechte:** Der Code läuft mit den vollen Rechten der Anwendung. Das ist kein Sandkasten. Eine
Ausnahme beim Laden, in einem Hook oder auf einer Seite nimmt nur das Plugin aus dem Spiel und
steht auf dem Bildschirm „Plugins“. Einen echten PHP-Abbruch fängt das nicht ab: Dann den Ordner
unter `plugins/` umbenennen.
## Die Vorlagen ## Die Vorlagen
### Bausteine ### Bausteine
+164 -1
View File
@@ -1,5 +1,166 @@
# Projektstatus # 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.
**Erledigt (keine Migration, kein Schemawechsel):**
- Symfony 7.4.22 → 7.4.23 (`composer update "symfony/*" --with-all-dependencies`, 24 Pakete); `composer audit` danach ohne Befund. `composer.json` unverändert.
- F1: Ein Formular lässt sich von `/beitraege`, `/kategorie/…`, `/schlagwort/…` absenden (auch unter `/en/…`). Vorher 404, die Anfrage war weg. `PageController::ARCHIVE_PATH`, `FormController::submit`.
- F2: Wahrheitswerte gehen als Zahl an die Datenbank (`form_submission.delivered`, `scheduled_task.enabled`, `plugin.active`).
- F3: Schrittformular mit Pflicht-Mehrfachauswahl meldet „Bitte wähle mindestens eine Option.“ statt mit einem TypeError stehen zu bleiben (`form-steps.js`).
- F5: `ArchiveUpdate::finish()` läuft unter `Updater::lock()`. Ein zweiter Abschluss zur selben Zeit meldet „läuft bereits“ und schaltet nichts zurück.
- E1: Im Editor wählen die Einträge im Akkordeon-Panel und „Bearbeiten“ in der Galerie wieder aus (lokale Funktion heißt jetzt `setProp`).
- S2: Kategorie- und Schlagwortnamen werden in der Archivüberschrift maskiert.
- S3: Eine veröffentlichte Seite holt nur aus dem Papierkorb zurück, wer veröffentlichen darf; die Meldung sagt, ob die Seite wieder öffentlich ist.
**Geprüft:** PHPUnit 695 grün (PHP 8.4, drei neue Tests und ein erweiterter), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser: Schrittformular (Luximmo, Pflichtgruppe per Skript gesetzt) zeigt die Meldung und bleibt im Schritt; Editor-Auswahl mit und ohne Korrektur verglichen. Nicht geprüft: PHP 8.2, `test:browser`, MySQL, das Update aus der Verwaltung auf einem Hosting.
**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.
**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)
**Anlass:** Umut: „Komplettanalyse – Speedcheck, Bugfix, Oberflächenverbesserung, Optimierung“. Nur Bestandsaufnahme, kein Code geändert. Bericht: `reports/komplettanalyse-2026-11.md` (nicht in Git).
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, php-cs-fixer, Twig-Lint, `npm audit` ohne Befund. `composer audit`: 12 Meldungen in 8 Symfony-Paketen (7.4.22, behoben in 7.4.23). Lighthouse 13.4.1 live gegen kant-it.de (noch 1.37.0), vier Seiten, je drei Läufe: Leistung 99–100, Barrierefreiheit 95 auf `/beitraege` und 96 auf der Beitragsseite, sonst 100. Rundgang durch 16 Verwaltungsseiten in 1440 und 390 px mit axe. Code-Durchsicht in vier Bereichen. Nicht geprüft: MySQL, `test:editor`, `test:browser`, Safari, PHP 8.2, Felddaten.
**Bekannte Fehler (Auswahl, Nummern wie im Bericht):** F1 Formular auf Archivseiten endet in 404; F2 `false` wird als `''` gebunden, auf MySQL scheitert vermutlich jede Formularabsendung (MySQL nicht ausgeführt); F3 `form-steps.js:162` wirft bei Pflicht-Mehrfachauswahl; F4 alte Seiten mit eigenem CSS können mit 500 antworten; F5 zweiter Updateschritt ohne Sperre; E1 lokale `choose` in `editor.js:360` verdeckt die Auswahlfunktion; E2 fünf endgültige Löschaktionen ohne Rückfrage; P1 Bilder laufen durch PHP (live rund 250 ms je Bild); P2 Bildstufen ohne 720 px.
**Nächster Schritt:** 1.40.1 mit Symfony-Update und den Korrekturen S1, F1, F2, F3, F5, E1, S2, S3 – nach Umuts Freigabe. Vorher klären, ob eine Kundeninstallation auf MySQL läuft.
## Fassung 1.40.0: Symbol „Haus (Fläche)“, Becker-Paket (10.11.2026)
**Anlass:** Umut wählte für das Startseiten-Symbol im Becker-Menü einen eigenen Entwurf; der Symbolsatz hat dafür `house` bekommen. Danach: „Release bauen und mir becker als zip paket ablegen“.
**Release:** Tag `v1.40.0` auf `main`, Releaseeintrag mit Zip (3700 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.
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, Twig-Lint, php-cs-fixer. `test:editor` und `test:browser` nicht gelaufen.
**Becker-Paket:** `var/becker-paket.zip` mit Fassung 1.40.0 neu exportiert, Kopie als `Downloads/becker-paket-1.40.0.zip`. Lässt sich nur auf 1.40.0 oder neuer importieren. Der Plugin-Zustand gehört nicht zum Paket: live nach dem Import unter Plugins „Bildvergleich“ einschalten, sonst bleibt der Vorher/Nachher-Regler leer.
## Neue Installation „Becker – Just In Time e. Kfm.“, Wiernsheim (10.11.2026)
**Anlass:** Umut lieferte `Becker-Just-In-Time-Version-5.zip` (ChatGPT-Sites-Entwurf), `Logo_Becker_original.pdf` und sieben Werbemotive mit Logos: „nachbauen, an die CI halten, eigenen Senf dazugeben, Bilder entlogolisieren und von ChatGPT ohne Logos nachbauen lassen“.
**Umgesetzt:** Die Fotos hat ChatGPT in Umuts Konto neu erzeugt (11 Motive, `var/becker-quelle/ki`, Originale in `var/becker-quelle/original`); die Asbestmotive zeigen dabei Schutzanzug und Atemschutz. `scripts/build-becker-assets.php` stellt das Logo aus dem PDF-Rasterbild frei (Kopf quer, Fuß gestapelt, drei Leistungssymbole, Favicon). `sh scripts/reset-becker.sh [var/becker-quelle]` baut alles neu: Navy #172234 + Logo-Gold #F4B43A, Montserrat/Manrope, Startseite mit Hero, Leistungskarten, Vorher/Nachher-Regler (Plugin „vergleich“), Ablauf, Einblicke-Galerie, Team, Seiten `/gartenbau`, `/zaunbau`, `/asbestsanierung`, `/kontakt` (Formular `anfrage` in zwei Schritten), Impressum, Datenschutz, 404, Paket `var/becker-paket.zip`. Port 8116 (dev) / 8117 (prod), Passwort in `var/becker-zugang.txt`.
**Geprüft:** Lighthouse im Produktionsmodus für `/`, `/gartenbau`, `/asbestsanierung`, `/kontakt`: Leistung mobil 97–98, Desktop 99–100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing je 100. Ganzseitige Aufnahmen 1440 und 390 px angesehen. Gold-Streifen als Inline-SVG (vorher CSS-Verlauf, sah treppig aus). Nicht geprüft: `test:browser`, Formularversand, Bedienung des Reglers per Tastatur von Hand.
**Kern:** neues Symbol `house` (geschlossene Hausform, Tür ausgespart) in `public/assets/icons.js`, per `node scripts/build-icons.mjs` in Twig und Validator; Becker nutzt es als Startseiten-Punkt im Menü. `tests/icons.test.mjs` und PHPUnit-Filter „Icon“ (26) grün. Das Becker-Paket braucht deshalb einen Kern mit diesem Symbol (nächstes Release).
**Offen:** Impressum – „e. Kfm.“ verlangt Registergericht und HRA-Nummer (fehlen); die im Entwurf als USt-IdNr. genannte „48116/36926“ ist eine Steuernummer und steht deshalb nicht drin. E-Mail: Entwurf `info@becker-justintime.de`, Logo-PDF `Becker-Justintime@web.de` – verwendet ist die aus dem Entwurf. Fotos sind KI-Symbolbilder (so im Bildnachweis). Domain/Hosting unbekannt.
## Fassung 1.39.1: SVG statt Schriftzeichen auf der Website, Luximmo-Immobilien (09.11.2026)
**Anlass:** Umut: „nur eigene SVGs nutzen“. Bildbetrachter, PDF-Betrachter, Nach-oben-Knopf, Video-Knopf, Menüpfeile, Brotkrumen, Weiterlesen, Blättern und Zitatsterne sind SVG (Masken in `site.css`, sonst eingebettet).
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, `test:editor`, `test:browser`, Twig-Lint; Symbole im Browser nachgemessen. Tag `v1.39.1`, Download stimmt mit der Prüfsumme überein.
**Offen:** Die Verwaltung enthält weiter Schriftzeichen (Editor-Symbole samt Plugin-Bausteinen, Pfeile auf Knöpfen). `var/luximmo-paket.zip` trägt Fassung 1.39.0 und hat die Immobilien-Seite. Im Menü von Luximmo bricht der fünfte Punkt zwischen etwa 1025 und 1130 px um. Immoscout-Plugin aktuell 1.5.2: Paket-Import ersetzt die Mediathek, der Abgleich holt die Bilder danach neu.
## Kern: Plugins mit PHP-Code; Plugin „ImmoScout-Abgleich“ für DEEG (09.11.2026)
**Anlass:** Umut: ImmoScout-Objekte im Takt abgleichen, kostenlos per URL, „100 % ein Plugin“, „wie WordPress“.
**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. **Release:** Tag `v1.39.0` auf `main`, Releaseeintrag mit Zip (3694 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand ohne das ImmoScout-Plugin; anonymer Download stimmt mit der Prüfsumme überein.
**Geprüft:** PHPUnit 692 grün (PHP 8.4, neu `PluginServerTest`), `npm test` 41, `test:editor`, Twig-Lint, php-cs-fixer. Auf einer Kopie der DEEG-Installation in der Verwaltung: einschalten, einstellen, echter Abgleich (5 Objekte, Bilder, Exposés), Liste, Objektseite 1440 und 390 px ohne waagrechtes Scrollen, Sitemap, Anfrage mit Objektangabe gespeichert, verschwundenes Objekt beim zweiten Lauf inaktiv mit 410. Nicht geprüft: Hosting, MySQL, Mailversand, Lighthouse der Objektseiten; die Abweichung Angebotszahl/gelesene Objekte wurde nicht einzeln simuliert.
**Nächster Schritt:** DEEG auf 1.39.0 aktualisieren, `var/immoscout-plugin-1.4.0.zip` nach `plugins/` entpacken, einschalten, Portal-Adresse eintragen, abgleichen, Baustein auf `/angebote` setzen. Vorher Zustimmung von DEEG einholen.
**Plugin:** liegt in `plugins/immoscout` (lokal von Git ausgeschlossen, Auslieferung als ZIP), nicht Teil des Kerns. ImmoScouts `robots.txt` verbietet automatisches Abrufen; das hat Umut entschieden, der Kunde sollte zustimmen. Eine Sperre wird nicht umgangen.
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
**Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration.
**Luximmo:** RealEstateAgent, Koordinaten aus OpenStreetMap (Gebäudetreffer), Logo-Bild für die Daten, Seitentitel „Hausverwaltung & Immobilienverkauf in Gaggenau | LUXIMMO“, Paket neu gebaut.
**Geprüft:** PHPUnit 687 grün (PHP 8.4, neu `testBusinessTypeLocationAndLogoReachTheStructuredData`), `npm test` 41, Twig-Lint, php-cs-fixer, Felder in der Verwaltung per Playwright (Laden, Logo entfernen und wählen, Speichern, Ausgabe auf der Website), Paketimport. Lighthouse nach dieser Änderung nicht erneut gelaufen.
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
**Release:** Tag `v1.38.0` auf `main`, Releaseeintrag mit Zip (3690 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand; anonymer Download stimmt mit der Prüfsumme überein. PHPUnit 687 grün unter PHP 8.4, PHP 8.2 nicht gelaufen. Luximmo-Paket mit Fassung 1.38.0 neu exportiert, es lässt sich nur auf 1.38.0 oder neuer importieren.
**Nächster Schritt:** Luximmo auf 1.38.0 einrichten, Paket importieren, Impressum vervollständigen; übrige Installationen über die Zentrale aktualisieren.
## Neue Installation „Luximmo Hausverwaltung“, Gaggenau (06.11.2026)
**Anlass:** Umut lieferte `luximmo-website.zip` (Onepager der Luximmo UG, Eslem Kurt, Hauptstr. 169, 76571 Gaggenau): „in KantSites nachbauen und wie die anderen Ablagen, gerne etwas ausbauen, und wie bei Immo DEEG ein Formular für Immo auf einer separaten Seite“.
**Erledigt (nur Inhalt, kein Kerncode):** Datenbank `var/luximmo.db`, Medien `var/luximmo-media`, Bildquellen `var/luximmo-assets`, Startkonfigurationen `luximmo` (8108) und `luximmo-prod` (8109), Inhaltspaket `var/luximmo-paket.zip`. Aufbau nur per `sh scripts/reset-luximmo.sh [assets-Ordner]` (Standbild aus dem Video: `build-luximmo-poster.mjs`; Logo, Seitensymbol aus dem goldenen Zeichen, WebP: `build-luximmo-assets.php`; Inhalte: `seed-luximmo.php`).
- **Startseite wie die Vorlage:** Kopf mit Menü links, Logo mittig, Knopf rechts; Hero mit dem Video als Hintergrundvideo der Sektion (ADR 125, WebM, Standbild als Hintergrundbild, Anhalten-Knopf oben rechts), gerundet; Intro mit Etikett und drei Kärtchen; drei Leistungen mit Foto; Über uns; Kontakt mit Formular `kontakt`. Farben, Instrument Sans (eigener Server) und Texte übernommen.
- **Ausgebaut:** je Leistung „Was dazugehört“ zum Aufklappen, „So einfach wechseln Sie zu uns“ in vier Schritten, häufige Fragen (FAQ-Auszeichnung), Fuß mit Logo, Anschrift und Leistungen; Impressum, Datenschutz, 404.
- **Separate Seite `/verwaltungsanfrage`** nach dem Muster des DEEG-Preisfinders: Formular `verwaltungsanfrage` in drei Schritten – Verwaltungsart als Bildkarten (die drei Leistungsfotos) und heutige Verwaltung; Objekt mit Feldern je Art (Einheiten/Stellplätze bei Miet- und WEG-Verwaltung, Beschluss nur bei WEG, Wohnungen/Vermietung nur bei Sondereigentum), Fläche, Baujahr, Ausstattung, Beginn; Lage und Kontakt; Zusammenfassung. Seitenspalte mit Ablauf und Karte der Ansprechpartnerin. Hero-Knopf, Menü „Anfrage“, Ablauf und FAQ verlinken darauf.
**Geprüft:** Bildschirmfotos 1440 und 390 px (kein waagrechtes Scrollen, keine Konsolenfehler, Klappmenü). Formular per Playwright durchgespielt: WEG blendet Einheiten und Beschluss ein, Sondereigentum-Felder aus; Zusammenfassung vollständig; Absenden mit Erfolgsmeldung und Eingang in `form_submission` (danach per Reset entfernt). `test:links` (4 Seiten, 24 Adressen, kein toter Verweis), `test:browser` gegen 8108 grün (axe, Tastatur, ohne JS; HTML 70 KB, 3 Skripte). Lighthouse (`audit.mjs`, je drei Läufe) auf 8109 für `/`, `/verwaltungsanfrage`, `/impressum`: mobil und Desktop überall 100 (`reports/lighthouse-luximmo`). php-cs-fixer auf den beiden neuen PHP-Skripten angewandt. Nicht geprüft: Mailversand, Safari/iOS (Video nur als WebM, kein ffmpeg zum Umwandeln in MP4), Editor auf diesen Dokumenten.
**Nachtrag (06.11.2026):** Domain lux-immo.net, E-Mail info@lux-immo.net (Umut). Beide Formulare gehen an info@, die Adresse steht im Kontaktbereich, im Fuß, in der Seitenspalte der Anfrage, in Impressum, Datenschutz und den strukturierten Daten. `test:links` und `test:browser` danach erneut grün.
**Nachtrag 2 (06.11.2026):** Umut: „Das Menü springt nicht zu den Bereichen“ und „ein Formular ‚Was ist Ihre Immobilie wert?‘“. *Menü:* Das eigene Menü-CSS setzte das Klappmenü auf `z-index:20`, die Klick-daneben-Fläche des Kerns (`.menu-burger[open]>summary::before`, z-index 29) lag darüber und fing jeden Klick ab – auf Tablet und Telefon kam kein Link an. Jetzt `z-index:30` wie im Kern. Per Playwright auf 1440 px, 900 px und iPhone 13 geprüft: Leistungen, Über uns und Bewertung springen bzw. öffnen, das Menü schließt. *Bewertung:* neue Seite `/immobilienbewertung` mit Formular `bewertung` (Objektart Wohnung/Haus/Mehrfamilienhaus/Grundstück als Bildkarten mit goldenen Piktogrammen aus `build-luximmo-assets.php`, Anlass; Eckdaten je Objektart – Grundstück ohne Wohnfläche/Zimmer mit Nutzbarkeit, Mehrfamilienhaus mit Einheiten und Jahresmiete, Wohnung mit Etage; Lage und Kontakt; Zusammenfassung; an info@lux-immo.net). Auf der Startseite ein dunkles Band „Was ist Ihre Immobilie wert?“ mit den vier Objektarten (Anker `#wert`), Menüpunkt „Bewertung“, Link im Fuß, Datenschutz ergänzt. Geprüft: Bedingungen für Grundstück und Mehrfamilienhaus, Absenden mit Eingang (danach per Reset entfernt), `test:links` (5 Seiten, 29 Adressen), `test:browser` grün (HTML 83 KB), Lighthouse `/` und `/immobilienbewertung` mobil und Desktop überall 100.
**Nachtrag 3 (09.11.2026):** Umuts Korrekturen und der Flyer. Linie unter dem Kopf abgedeckt (Kopfsektion `margin-bottom:-1px`, die Linie zeichnet der Kern an `.site-header`). „So einfach wechseln“ und „Was ist Ihre Immobilie wert?“ sind jetzt Karten in einer normalen Sektion, gleiche Breite wie die Leistungen (bei 2000 px gemessen: 200–1800). Abstand zwischen Sektionen 112 statt 180 px, mobil 64 statt 88 px. Optionsfelder der Bildkarten rund (die Feldregeln gaben ihnen Polster und Mindesthöhe). Vom Flyer: Slogan „Ein Lichtblick für Ihre Immobilie.“ als H1 (ersetzt „Hausverwaltung, die man nicht merkt.“), „Ihr neuer Immobilienpartner vor Ort“, Immobilienverkauf als erste Leistung (Foto: Standbild des Videos, ein eigenes fehlt), WEG-Text, Regionsband, Telefon 07225 5080756, Hauptstraße 169. Das erfundene Zitat auf der Monogramm-Karte ist durch den Slogan ersetzt, „feste Preise je Einheit“ aus der FAQ entfernt. Noch vom Kunden zu bestätigen: „kostenlos“ bei der Bewertung, die Aussage zur Erlaubnis nach § 34c GewO.
**Livegang-Paket (09.11.2026):** `var/luximmo-paket.zip` (Fassung 1.37.0, 6 Seiten, 26 Mediendateien, Video, 3 Formulare, ohne Konten und Anfragen). In eine frische Testdatenbank importiert (braucht wie jede Installation den Website-Eintrag des Installers; nur migriert scheitert der Import am Fremdschlüssel, ohne etwas zu ändern). Darauf geprüft: alle Seiten, sitemap.xml, llms.txt und Video 200, `test:links` ohne toten Verweis, `test:browser` im Entwicklungsmodus grün (im Produktionsmodus nicht möglich, die Prüfung braucht `X-Kant-Queries`), Lighthouse für `/`, `/immobilienbewertung`, `/verwaltungsanfrage`, `/impressum` mobil und Desktop überall 100. Nicht geprüft: Hosting, Mailversand, Schriftenabruf auf dem Server, Safari/iOS.
**Logo als SVG (09.11.2026):** Kundendatei `var/luximmo-assets/logo-original.svg`; `seed-luximmo.php` schneidet sie zu (ohne weiße Fläche und IDs) und setzt sie als „Eigenes HTML“ in Kopf und Fuß, die Mediathek nimmt keine SVGs an. Das Seitensymbol bleibt PNG. Tausch künftig im Baustein (nur Administrator). Paket neu gebaut.
**Offen (von Umut/Kunde):** im Impressum Geschäftsführung, Register, USt-ID, § 34c-Aufsicht und Bildnachweis als „NOCH ZU ERGÄNZEN“, ein Portrait von Eslem Kurt (jetzt Monogramm-Karte), eine MP4-Fassung des Videos für ältere Safari.
**Nächster Schritt:** Impressum vervollständigen, dann Livegang nach `docs/LIVEGANG.md`.
## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026) ## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026)
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“. **Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
@@ -14,7 +175,9 @@
**Geprüft:** PHPUnit 686/686 grün unter PHP 8.4 (auch `UpdaterTest`) und 8.2 (dort nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings`); php-cs-fixer ohne Befund, `lint:twig`, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt). Lokale `var/cms.db` auf `Version20261104000100` migriert. **Geprüft:** PHPUnit 686/686 grün unter PHP 8.4 (auch `UpdaterTest`) und 8.2 (dort nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings`); php-cs-fixer ohne Befund, `lint:twig`, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt). Lokale `var/cms.db` auf `Version20261104000100` migriert.
**Nächster Schritt:** Release 1.37.0, dann auf kant-it.de hochladen und in der Hero-Sektion als Hintergrundvideo wählen. **Release:** Tag `v1.37.0` auf `main` (Zweig `videos` per Fast-Forward übernommen), Releaseeintrag mit Zip (3685 Dateien, 5,7 MB), `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (ec59d058…), `releases/latest` meldet v1.37.0.
**Nächster Schritt:** In der Zentrale auf 1.37.0 aktualisieren, dann auf kant-it.de ein Video hochladen und in der Hero-Sektion als Hintergrundvideo wählen; dabei Safari/iOS und Lighthouse mit Video prüfen.
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026) ## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.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`: 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. 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. 2. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
3. **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. **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. **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. 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. 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.** 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. 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 #RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] #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] RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L] 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 # 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 # 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. # 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> <IfModule mod_expires.c>
ExpiresActive On ExpiresActive On
ExpiresByType image/webp "access plus 1 year" ExpiresByType image/webp "access plus 1 year"
@@ -68,6 +82,10 @@ RewriteRule ^ index.php [L]
<FilesMatch "\.woff2$"> <FilesMatch "\.woff2$">
Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Origin "*"
</FilesMatch> </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 # 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. # ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
Header always unset X-Powered-By Header always unset X-Powered-By
+11 -4
View File
@@ -843,6 +843,7 @@ details.card > summary:hover { background: #fafbf8 }
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)} .favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px} .favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)} .favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.logo-preview{object-fit:contain}
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px} .favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
.favicon-editor[hidden]{display:none} .favicon-editor[hidden]{display:none}
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none} .favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
@@ -1154,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-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px } .update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px } .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, .confirm-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::backdrop, .confirm-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px } .update-dialog h2, .confirm-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 .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();
},
});
});
})();
+17 -5
View File
@@ -357,7 +357,7 @@ function renderContent(block, panel) {
}; };
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das // Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat. // Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
const choose = (key, next, version, focus) => { const setProp = (key, next, version, focus) => {
history.change(doc => { history.change(doc => {
const current = locate(doc.blocks, selected)?.block; const current = locate(doc.blocks, selected)?.block;
if (!current) return; if (!current) return;
@@ -370,7 +370,7 @@ function renderContent(block, panel) {
document.querySelector(focus)?.focus({ preventScroll: true }); document.querySelector(focus)?.focus({ preventScroll: true });
}; };
const iconField = (label, value, options = {}) => panel.append(renderIconField({ const iconField = (label, value, options = {}) => panel.append(renderIconField({
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'), label, value, none: options.none, onChoose: name => setProp('icon', name, options.version, '.icon-field-change'),
})); }));
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste. // Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
const segmented = (label, key, current, options, version) => { const segmented = (label, key, current, options, version) => {
@@ -381,7 +381,7 @@ function renderContent(block, panel) {
for (const [value, text] of options) { for (const [value, text] of options) {
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text; const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
option.setAttribute('aria-pressed', String(value === current)); option.setAttribute('aria-pressed', String(value === current));
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); }); option.addEventListener('click', () => { if (value !== current) setProp(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
group.append(option); group.append(option);
} }
wrap.append(caption, group); panel.append(wrap); wrap.append(caption, group); panel.append(wrap);
@@ -1920,7 +1920,15 @@ function onKey(event) {
if (!history || busy) return; if (!history || busy) return;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) { 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. // Keep native text-field undo intact; the toolbar always undoes the complete document.
if (inField(event.target)) return; if (inField(event.target)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); } 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 // 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. // 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); event.preventDefault(); actions.delete(selected);
} }
} }
+12 -4
View File
@@ -39,10 +39,18 @@
/* latin */ /* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} @font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */ /* latin-ext */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} @font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */ /* latin */
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} @font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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; } 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]'); const button = event.target.closest('[data-remove-field]');
if (!button) return; 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 => { list.addEventListener('input', event => {
const card = event.target.closest('[data-field]'); const card = event.target.closest('[data-field]');
+8 -2
View File
@@ -159,8 +159,14 @@
if (group.closest('[data-inactive]')) continue; if (group.closest('[data-inactive]')) continue;
const note = group.querySelector('.field-error[data-live]'); const note = group.querySelector('.field-error[data-live]');
if (!group.querySelector('input:checked')) { if (!group.querySelector('input:checked')) {
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } })); if (!note) {
else note.hidden = false; // dataset lässt sich nicht als Ganzes zuweisen: Object.assign warf hier, und „Weiter“
// tat ohne Meldung nichts.
const hint = el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.'));
hint.dataset.live = '';
hint.setAttribute('role', 'alert');
group.append(hint);
} else note.hidden = false;
group.classList.add('has-error'); group.classList.add('has-error');
group.querySelector('input')?.focus(); group.querySelector('input')?.focus();
return false; return false;
+3 -2
View File
@@ -74,6 +74,7 @@ export const ICON_PATHS = {
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z', star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
// Haus, Garten und Handwerk // Haus, Garten und Handwerk
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20', home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3', building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z', wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z', hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
@@ -128,7 +129,7 @@ export const ICON_LABELS = {
mic: 'Mikrofon', book: 'Buch', mic: 'Mikrofon', book: 'Buch',
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz', user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern', eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal', home: 'Haus', house: 'Haus (Fläche)', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar', leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto', thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung', check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
@@ -144,7 +145,7 @@ export const ICON_GROUPS = [
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']], ['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']], ['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']], ['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']], ['Haus & Handwerk', ['home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']], ['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']], ['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']],
]; ];
+7 -4
View File
@@ -1,3 +1,6 @@
/* Symbole als SVG: Schriftzeichen macht ein Telefon zu Emoji. */
const symbol = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${p}"/></svg>`;
/* /*
* Der Bildbetrachter. * Der Bildbetrachter.
* *
@@ -23,9 +26,9 @@
<img class="zoom-image" alt=""> <img class="zoom-image" alt="">
<figcaption class="zoom-caption"></figcaption> <figcaption class="zoom-caption"></figcaption>
</figure> </figure>
<button class="zoom-close" type="button" aria-label="Schließen">×</button> <button class="zoom-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button> <button class="zoom-prev" type="button" aria-label="Vorheriges Bild">${symbol('M15 5.5 8.5 12l6.5 6.5')}</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button> <button class="zoom-next" type="button" aria-label="Nächstes Bild">${symbol('m9 5.5 6.5 6.5L9 18.5')}</button>
<p class="zoom-counter" aria-live="polite"></p> <p class="zoom-counter" aria-live="polite"></p>
`; `;
document.body.append(dialog); document.body.append(dialog);
@@ -138,7 +141,7 @@
<p class="pdf-title" id="pdf-dialog-title"></p> <p class="pdf-title" id="pdf-dialog-title"></p>
<a class="pdf-action pdf-download" download>Herunterladen</a> <a class="pdf-action pdf-download" download>Herunterladen</a>
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a> <a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
<button class="pdf-close" type="button" aria-label="Schließen">×</button> <button class="pdf-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
</div> </div>
<iframe class="pdf-frame" title="PDF-Dokument"></iframe> <iframe class="pdf-frame" title="PDF-Dokument"></iframe>
`; `;
+41
View File
@@ -0,0 +1,41 @@
import { openMedia } from './media.js';
/*
* The logo for the structured data: one image from the media library, shown as a small preview.
* It is saved with the form, like every other value on this screen.
*/
const field = document.querySelector('[data-logo-field]');
if (field) {
const input = field.querySelector('[name="organisationLogoMediaId"]');
const current = field.querySelector('.favicon-current');
const choose = field.querySelector('[data-logo-choose]');
const remove = field.querySelector('[data-logo-remove]');
const show = (label, src = '') => {
current.replaceChildren();
if (src) {
const image = new Image(64, 64);
image.className = 'favicon-preview logo-preview';
image.alt = '';
image.src = src;
current.append(image);
}
const text = document.createElement('span');
text.textContent = label;
current.append(text);
choose.textContent = input.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
remove.hidden = !input.value;
};
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: items => {
if (!items.length) return;
input.value = items[0].id;
show(`„${items[0].title}“ – wird beim Speichern übernommen.`, items[0].variants[0].src);
choose.focus();
} }));
remove.addEventListener('click', () => {
input.value = '';
show('Kein Logo gewählt – wird beim Speichern übernommen.');
choose.focus();
});
}
+17 -7
View File
@@ -1,3 +1,5 @@
/* Symbole als eingebettete SVG-Masken: Schriftzeichen macht ein Telefon zu Emoji. */
:root{--kant-svg-unten:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");--kant-svg-oben:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 15 6-6 6 6'/%3E%3C/svg%3E");--kant-svg-rechts:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 5.5 6.5 6.5L9 18.5'/%3E%3C/svg%3E");--kant-svg-pfeil:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5'/%3E%3C/svg%3E");--kant-svg-pfeil-links:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 12H4.5M11 5.5 4.5 12 11 18.5'/%3E%3C/svg%3E");--kant-svg-pause:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14m8-14v14'/%3E%3C/svg%3E");--kant-svg-play:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}
@charset "UTF-8"; @charset "UTF-8";
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im /* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */ Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
@@ -19,14 +21,14 @@ p{hyphens:auto}
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline} .menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
.menu-list [aria-current=page]{font-weight:700;text-decoration:none} .menu-list [aria-current=page]{font-weight:700;text-decoration:none}
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none} .menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
.menu-branch>summary::after{content:' ▾'} .menu-branch>summary::after{content:'';display:inline-block;width:.75em;height:.75em;background:currentColor;-webkit-mask:var(--kant-svg-unten) center/contain no-repeat;mask:var(--kant-svg-unten) center/contain no-repeat;margin-left:.3em;vertical-align:-.05em}
.menu-branch[open]>summary::after{content:' ▴'} .menu-branch[open]>summary::after{-webkit-mask-image:var(--kant-svg-oben);mask-image:var(--kant-svg-oben)}
.menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px} .menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px} .menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* Posts */ /* Posts */
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content} .post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
.post-more::after{content:'→';transition:transform .2s} .post-more::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;transition:transform .2s}
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0} .post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px} .post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)} .post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
@@ -66,7 +68,9 @@ p{hyphens:auto}
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65} .block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
.block-quote figcaption{font-size:14px;font-weight:600} .block-quote figcaption{font-size:14px;font-weight:600}
.block-quote figcaption span{font-weight:400;opacity:.85} .block-quote figcaption span{font-weight:400;opacity:.85}
.quote-rating{margin:0;letter-spacing:3px} .quote-rating{margin:0}
.quote-rating svg{display:inline-block;width:1.1em;height:1.1em;margin-right:2px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
.quote-rating .quote-star-voll{fill:currentColor}
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px} .block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block} .block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px} .kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
@@ -170,7 +174,7 @@ p{hyphens:auto}
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}} @media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px} .block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6} .block-breadcrumb li+li::before{content:'';display:inline-block;width:.85em;height:.85em;background:currentColor;-webkit-mask:var(--kant-svg-rechts) center/contain no-repeat;mask:var(--kant-svg-rechts) center/contain no-repeat;margin-right:10px;opacity:.6;vertical-align:-.08em}
.block-breadcrumb a{text-decoration:none} .block-breadcrumb a{text-decoration:none}
.block-breadcrumb a:hover{text-decoration:underline} .block-breadcrumb a:hover{text-decoration:underline}
@@ -247,8 +251,8 @@ body.consent-open{padding-bottom:320px}
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none} .section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
.has-video>.section-inner{position:relative;z-index:1} .has-video>.section-inner{position:relative;z-index:1}
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer} .video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
.video-toggle::before{content:"❚❚"} .video-toggle::before{content:'';display:inline-block;width:20px;height:20px;background:currentColor;-webkit-mask:var(--kant-svg-pause) center/contain no-repeat;mask:var(--kant-svg-pause) center/contain no-repeat;display:block;margin:auto}
.video-toggle.is-paused::before{content:"▶"} .video-toggle.is-paused::before{-webkit-mask-image:var(--kant-svg-play);mask-image:var(--kant-svg-play)}
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px} .video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
.video-toggle[hidden]{display:none} .video-toggle[hidden]{display:none}
.block-html{max-width:100%} .block-html{max-width:100%}
@@ -603,6 +607,10 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer} .zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)} .zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px} .zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
.zoom-close svg,.zoom-prev svg,.zoom-next svg,.pdf-close svg,.scroll-top svg{display:block;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.zoom-close svg,.zoom-prev svg,.zoom-next svg{width:26px;height:26px}
.pdf-close svg{width:20px;height:20px}
.scroll-top svg{width:46%;height:46%}
.zoom-close{top:18px;right:18px} .zoom-close{top:18px;right:18px}
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)} .zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
.zoom-next{right:18px;top:50%;transform:translateY(-50%)} .zoom-next{right:18px;top:50%;transform:translateY(-50%)}
@@ -644,3 +652,5 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.tone-ink,.tone-mint{background:transparent;color:#000} .tone-ink,.tone-mint{background:transparent;color:#000}
[data-step][hidden],.block-form [hidden]{display:block!important} [data-step][hidden],.block-form [hidden]{display:block!important}
} }
.post-pager a[rel="prev"]::before{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil-links) center/contain no-repeat;mask:var(--kant-svg-pfeil-links) center/contain no-repeat;margin-right:.35em;vertical-align:-.12em}
.post-pager a[rel="next"]::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;margin-left:.35em;vertical-align:-.12em}
+3 -3
View File
@@ -56,14 +56,14 @@ export function initSliders(doc = document) {
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)])); go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
}; };
const manual = action => { playing = false; clearTimeout(timer); update(); action(); }; const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev'; const previous = button('', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count); const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next'; const next = button('', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const dots = []; const dots = [];
if (slider.classList.contains('slider-dots')) { if (slider.classList.contains('slider-dots')) {
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group); const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
slides.forEach((slide, i) => { slides.forEach((slide, i) => {
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot); const dot = button('', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
}); });
} }
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null; const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php'; const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' })); 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 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 port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`; 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'], { 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 }); await mkdir('reports/admin-quality', { recursive: true });
let browser, page; let browser, page;
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* Bereitet Logo und Fotos von „Becker – Just In Time e. Kfm.“ für die Mediathek des Nachbaus vor.
*
* Quellordner (var/becker-quelle):
* - logo-original.png: das Rasterbild aus Logo_Becker_original.pdf (1536 × 1024, Navy-Grund #172234),
* - ki/*.png: die von ChatGPT ohne Logos neu erzeugten Fotos (10.11.2026, nach Umuts Vorlagen).
*
* Daraus entstehen im Zielordner:
* - logo.png: Bagger-Signet links, „BECKER / JUST IN TIME e. Kfm.“ rechts – für den Kopf,
* - logo-gross.png: das Logo gestapelt mit den drei Leistungssymbolen – für den Fuß,
* - favicon.png: das Bagger-Signet auf Navy, 192 × 192,
* - je Foto eine WebP-Datei (Startbild 1920 px, sonst 1600 px breit).
*
* Der Navy-Grund des Logos wird weich freigestellt: Deckkraft aus dem Farbabstand zum Grund,
* die Kantenfarbe zurückgerechnet. So passt das Logo auf jede dunkle Fläche.
*
* Aufruf: php scripts/build-becker-assets.php <Quellordner> <Zielordner>
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/logo-original.png')) {
fwrite(STDERR, "Aufruf: php scripts/build-becker-assets.php <Ordner mit logo-original.png und ki/> <Zielordner>\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
$logo = imagecreatefrompng($quelle.'/logo-original.png');
$grund = [23, 34, 52];
/** Ein Ausschnitt des Logos mit freigestelltem Navy-Grund. */
$freistellen = static function (int $x, int $y, int $b, int $h) use ($logo, $grund): GdImage {
$bild = imagecreatetruecolor($b, $h);
imagealphablending($bild, false);
imagesavealpha($bild, true);
for ($j = 0; $j < $h; ++$j) {
for ($i = 0; $i < $b; ++$i) {
$c = imagecolorat($logo, $x + $i, $y + $j);
$p = [$c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
$abstand = max(abs($p[0] - $grund[0]), abs($p[1] - $grund[1]), abs($p[2] - $grund[2]));
$a = min(1.0, max(0.0, ($abstand - 6) / 90));
if ($a <= 0.0) {
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, 0, 0, 0, 127));
continue;
}
$f = array_map(static fn (int $k): int => (int) max(0, min(255, round($grund[$k] + ($p[$k] - $grund[$k]) / $a))), [0, 1, 2]);
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, $f[0], $f[1], $f[2], (int) round(127 * (1 - $a))));
}
}
return $bild;
};
/** Verkleinert mit Durchsichtigkeit. */
$verkleinern = static function (GdImage $bild, int $breite): GdImage {
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
$neu = imagecreatetruecolor($breite, $hoehe);
imagealphablending($neu, false);
imagesavealpha($neu, true);
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
return $neu;
};
// Bänder im Original (gemessen): Signet y 15–455 / x 360–1196, „BECKER“ y 466–636,
// „JUST IN TIME e. Kfm.“ mit Linien y 647–701 / x 162–1382, Leistungssymbole y 722–897.
$signet = $freistellen(352, 8, 852, 456);
$schrift = $freistellen(154, 458, 1236, 252);
// Querformat für den Kopf: Signet links (so hoch wie die Schrift plus etwas), Schrift rechts.
$sh = 330;
$sb = (int) round(852 * $sh / 456);
$quer = imagecreatetruecolor($sb + 36 + 1236, $sh);
imagealphablending($quer, false);
imagesavealpha($quer, true);
imagefill($quer, 0, 0, imagecolorallocatealpha($quer, 0, 0, 0, 127));
imagealphablending($quer, true);
imagecopyresampled($quer, $signet, 0, 0, 0, 0, $sb, $sh, 852, 456);
imagecopy($quer, $schrift, $sb + 36, (int) round(($sh - 252) / 2) + 10, 0, 0, 1236, 252);
imagealphablending($quer, false);
imagepng($verkleinern($quer, 760), $ziel.'/logo.png', 9);
echo 'logo.png: 760 × '.(int) round($sh * 760 / imagesx($quer))."\n";
$gross = $freistellen(100, 8, 1336, 898);
imagepng($verkleinern($gross, 560), $ziel.'/logo-gross.png', 9);
echo "logo-gross.png: 560 px breit\n";
// Die drei Leistungssymbole des Logos (Blätter, Zaun, Dach) einzeln, quadratisch, 128 px.
foreach (['symbol-garten' => [250, 716, 186, 120], 'symbol-zaun' => [679, 716, 176, 124], 'symbol-asbest' => [1071, 716, 226, 120]] as $name => [$x, $y, $b, $h]) {
$teil = $freistellen($x, $y, $b, $h);
$q = max($b, $h);
$quadrat = imagecreatetruecolor(128, 128);
imagealphablending($quadrat, false);
imagesavealpha($quadrat, true);
imagefill($quadrat, 0, 0, imagecolorallocatealpha($quadrat, 0, 0, 0, 127));
$f = 128 / $q;
imagecopyresampled($quadrat, $teil, (int) round((128 - $b * $f) / 2), (int) round((128 - $h * $f) / 2), 0, 0, (int) round($b * $f), (int) round($h * $f), $b, $h);
imagepng($quadrat, $ziel.'/'.$name.'.png', 9);
echo "$name.png: 128 × 128\n";
}
// Seitensymbol: Signet mittig auf Navy.
$fav = imagecreatetruecolor(192, 192);
imagefill($fav, 0, 0, imagecolorallocate($fav, ...$grund));
imagecopyresampled($fav, $logo, 14, 22 + (int) round((148 - 164 * 440 / 836) / 2), 360, 15, 164, (int) round(164 * 440 / 836), 836, 440);
imagepng($fav, $ziel.'/favicon.png', 9);
echo "favicon.png: 192 × 192\n";
// Fotos: ChatGPT liefert PNG; als WebP auf Zielbreite.
foreach (['haus' => 1920, 'team' => 1600, 'zaun' => 1600, 'zauntor' => 1600, 'asbest' => 1600, 'dach' => 1600,
'bagger' => 1600, 'terrasse' => 1600, 'zisterne' => 1600, 'vorher' => 1600, 'nachher' => 1600] as $name => $breite) {
$pfad = $quelle.'/ki/'.$name.'.png';
if (!is_file($pfad)) {
fwrite(STDERR, "Foto fehlt: $pfad\n");
exit(1);
}
$bild = imagecreatefrompng($pfad);
$breite = min($breite, imagesx($bild));
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
$neu = imagecreatetruecolor($breite, $hoehe);
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
imagewebp($neu, $ziel.'/'.$name.'.webp', 80);
echo "$name.webp: $breite × $hoehe\n";
}
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* Bereitet die Bilder des Luximmo-Entwurfs (luximmo-website.zip) für die Mediathek vor.
*
* Aus dem Ordner „assets“ des Pakets entstehen:
* - logo.png: das Logo ohne Rand, 640 px breit (Original 2596 × 2274, Zeichen und Schriftzug),
* - favicon.png: nur das goldene Zeichen (Kreis mit Häusern), quadratisch auf Weiß, 512 px,
* - mietverwaltung.webp, weg.webp, sonder.webp: die drei Leistungsfotos als WebP,
* - hero.webm: das Hero-Video unverändert,
* - hero-poster.webp: das Standbild aus dem Video (vorher mit build-luximmo-poster.mjs als PNG).
*
* Aufruf: php scripts/build-luximmo-assets.php <assets-Ordner des Pakets> <Zielordner> [poster.png]
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
$poster = $argv[3] ?? '';
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/luximmo-logo.png')) {
fwrite(STDERR, "Aufruf: php scripts/build-luximmo-assets.php <Ordner mit luximmo-logo.png usw.> <Zielordner> [poster.png]\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
/** Verkleinert ein Bild auf eine Zielbreite und speichert es als WebP. */
$webp = static function (GdImage $bild, string $name, int $breite) use ($ziel): void {
$breite = min($breite, imagesx($bild));
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
$neu = imagecreatetruecolor($breite, $hoehe);
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
imagewebp($neu, $ziel.'/'.$name, 80);
echo "$name: {$breite} × {$hoehe}\n";
};
foreach (['mietverwaltung', 'weg', 'sonder'] as $foto) {
$bild = imagecreatefromjpeg($quelle.'/'.$foto.'.jpg') ?: exit("Foto fehlt: $foto.jpg\n");
$webp($bild, $foto.'.webp', 1600);
}
if ('' !== $poster) {
$webp(imagecreatefrompng($poster) ?: exit("Standbild lässt sich nicht lesen: $poster\n"), 'hero-poster.webp', 1920);
}
copy($quelle.'/hero.webm', $ziel.'/hero.webm');
echo "hero.webm übernommen\n";
// Das Logo: Grenzen aller nicht weißen, nicht durchsichtigen Punkte, dazu die des Goldtons allein.
$logo = imagecreatefrompng($quelle.'/luximmo-logo.png');
imagepalettetotruecolor($logo);
$alles = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
$gold = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
for ($y = 0; $y < imagesy($logo); $y += 2) {
for ($x = 0; $x < imagesx($logo); $x += 2) {
$c = imagecolorat($logo, $x, $y);
[$a, $r, $g, $b] = [$c >> 24 & 0x7F, $c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
if ($a > 100 || ($r > 235 && $g > 235 && $b > 235)) {
continue;
}
$alles = [min($alles[0], $x), min($alles[1], $y), max($alles[2], $x), max($alles[3], $y)];
if ($r - $b > 40) {
$gold = [min($gold[0], $x), min($gold[1], $y), max($gold[2], $x), max($gold[3], $y)];
}
}
}
/** Ein Ausschnitt des Logos auf eine Fläche gesetzt: durchsichtig oder weiß. */
$ausschnitt = static function (array $kasten, int $breite, int $hoehe, int $rand, bool $weiss) use ($logo): GdImage {
$neu = imagecreatetruecolor($breite, $hoehe);
imagealphablending($neu, false);
imagesavealpha($neu, true);
imagefill($neu, 0, 0, $weiss ? imagecolorallocate($neu, 255, 255, 255) : imagecolorallocatealpha($neu, 255, 255, 255, 127));
imagealphablending($neu, true);
[$x1, $y1, $x2, $y2] = $kasten;
$b = $x2 - $x1 + 1;
$h = $y2 - $y1 + 1;
$faktor = min(($breite - 2 * $rand) / $b, ($hoehe - 2 * $rand) / $h);
$zb = (int) round($b * $faktor);
$zh = (int) round($h * $faktor);
imagecopyresampled($neu, $logo, (int) (($breite - $zb) / 2), (int) (($hoehe - $zh) / 2), $x1, $y1, $zb, $zh, $b, $h);
return $neu;
};
$breite = 640;
$hoehe = (int) round(($alles[3] - $alles[1] + 1) * $breite / ($alles[2] - $alles[0] + 1));
imagepng($ausschnitt($alles, $breite, $hoehe, 0, false), $ziel.'/logo.png', 9);
echo "logo.png: {$breite} × {$hoehe}\n";
imagepng($ausschnitt($gold, 512, 512, 36, true), $ziel.'/favicon.png', 9);
echo "favicon.png: 512 × 512 (Zeichen ohne Schriftzug)\n";
// Piktogramme für die Objektart der Immobilienbewertung: goldene Linien auf Creme, wie das Logo.
// Vierfach gezeichnet und verkleinert, damit die dicken Linien glatte Kanten bekommen.
$piktogramm = static function (string $name, callable $zeichnen) use ($ziel): void {
$s = 4;
$gross = imagecreatetruecolor(480 * $s, 360 * $s);
imagefill($gross, 0, 0, imagecolorallocate($gross, 0xF7, 0xF4, 0xEF));
$gold = imagecolorallocate($gross, 0xBD, 0xA0, 0x76);
imagesetthickness($gross, 5 * $s);
$p = static fn (array $punkte): array => array_map(static fn (int $v): int => $v * $s, $punkte);
$linie = static fn (int $x1, int $y1, int $x2, int $y2) => imageline($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
$rahmen = static fn (int $x1, int $y1, int $x2, int $y2) => imagepolygon($gross, $p([$x1, $y1, $x2, $y1, $x2, $y2, $x1, $y2]), $gold);
$flaeche = static fn (int $x1, int $y1, int $x2, int $y2) => imagefilledrectangle($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
$zeichnen($linie, $rahmen, $flaeche, static fn (array $punkte) => imagepolygon($gross, $p($punkte), $gold));
$klein = imagecreatetruecolor(480, 360);
imagecopyresampled($klein, $gross, 0, 0, 0, 0, 480, 360, 480 * $s, 360 * $s);
imagewebp($klein, $ziel.'/'.$name, 90);
echo "$name: 480 × 360\n";
};
$piktogramm('option-wohnung.webp', static function ($linie, $rahmen, $flaeche): void {
$linie(110, 300, 370, 300);
$rahmen(180, 80, 300, 300);
foreach ([110, 150, 190, 230] as $y) {
foreach ([200, 228, 256] as $x) {
150 === $y && 228 === $x ? $flaeche($x, $y, $x + 22, $y + 22) : $rahmen($x, $y, $x + 22, $y + 22);
}
}
$rahmen(225, 262, 255, 300);
});
$piktogramm('option-haus.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
$linie(110, 300, 370, 300);
$vieleck([150, 175, 240, 98, 330, 175]);
$rahmen(170, 175, 310, 300);
$rahmen(226, 240, 256, 300);
$rahmen(186, 198, 214, 226);
$rahmen(268, 198, 296, 226);
});
$piktogramm('option-mehrfamilienhaus.webp', static function ($linie, $rahmen): void {
$linie(100, 300, 380, 300);
$rahmen(140, 100, 340, 300);
$linie(140, 122, 340, 122);
foreach ([140, 178, 216] as $y) {
foreach ([162, 202, 256, 296] as $x) {
$rahmen($x, $y, $x + 22, $y + 22);
}
}
$rahmen(224, 256, 256, 300);
});
$piktogramm('option-grundstueck.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
$vieleck([100, 290, 380, 290, 325, 195, 155, 195]);
$linie(240, 245, 240, 120);
$vieleck([240, 120, 290, 138, 240, 156]);
foreach ([[100, 290], [380, 290], [325, 195], [155, 195]] as [$x, $y]) {
$flaeche($x - 7, $y - 7, $x + 7, $y + 7);
}
});
+37
View File
@@ -0,0 +1,37 @@
// Holt ein Standbild aus dem Hero-Video von Luximmo: Es steht als Hintergrundbild der Sektion,
// bis das Video läuft (und für immer bei „Bewegung reduzieren“ oder Datensparen). ffmpeg gibt es
// hier nicht, Chromium spielt WebM ab und zeichnet das Bild in ein Canvas.
//
// Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]
import { readFileSync, writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
const [quelle, ziel, sekunde = '0.4'] = process.argv.slice(2);
if (!quelle || !ziel) {
console.error('Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]');
process.exit(1);
}
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.setContent('<video muted playsinline></video>');
const daten = readFileSync(quelle).toString('base64');
const png = await page.evaluate(async ([b64, t]) => {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const video = document.querySelector('video');
video.src = URL.createObjectURL(new Blob([bytes], { type: 'video/webm' }));
await new Promise((ok, fehler) => { video.onloadeddata = ok; video.onerror = () => fehler(new Error('Video lässt sich nicht lesen')); });
video.currentTime = Math.min(Number(t), Math.max(0, video.duration - 0.1));
await new Promise((ok) => { video.onseeked = ok; });
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
return { url: canvas.toDataURL('image/png'), breite: video.videoWidth, hoehe: video.videoHeight, dauer: video.duration };
}, [daten, sekunde]);
writeFileSync(ziel, Buffer.from(png.url.split(',')[1], 'base64'));
console.log(`Standbild ${png.breite} × ${png.hoehe} (Video ${png.dauer.toFixed(1)} s) → ${ziel}`);
} finally {
await browser.close();
}
+1 -1
View File
@@ -7,7 +7,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php'; const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 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'], { 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, 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', stdio: 'ignore',
}); });
server.on('error', error => { throw error; }); 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
const before = await hash(); const before = await hash();
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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' }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 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 php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 port = probe.address().port; await new Promise(resolve => probe.close(resolve));
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Wirft die Becker-Installation (Becker – Just In Time e. Kfm., Wiernsheim) weg und baut sie neu auf.
# Nur fuer den Aufbau gedacht.
# Erstes Argument (optional): Quellordner mit logo-original.png (Rasterbild aus Logo_Becker_original.pdf)
# und ki/*.png (die ohne Logos neu erzeugten Fotos). Dann entstehen Logo, Symbole und WebP-Fotos neu.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/becker.db"
export MEDIA_DIRECTORY="$root/var/becker-media"
assets="$root/var/becker-assets"
if [ -n "$1" ]; then
"$php" scripts/build-becker-assets.php "$1" "$assets"
fi
if [ ! -f "$assets/haus.webp" ] || [ ! -f "$assets/logo.png" ]; then
echo "Bilder fehlen: erst mit dem Quellordner (z. B. var/becker-quelle) als Argument aufrufen." >&2
exit 1
fi
rm -f "$root/var/becker.db"
rm -rf "$root/var/becker-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-becker.php "$assets"
# Manrope und Montserrat vom eigenen Server; der Abruf ergaenzt fonts.css nur.
if [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
rm -f "$root/var/becker-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/becker-paket.zip"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Wirft die Luximmo-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument (optional): der Ordner „assets“ aus luximmo-website.zip (Logo, Fotos, hero.webm).
# Dann entstehen Logo, Seitensymbol, WebP-Fotos und das Standbild des Videos neu.
# Empfaenger der Formulare per LUXIMMO_MAIL, sonst eine Platzhalteradresse.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/luximmo.db"
export MEDIA_DIRECTORY="$root/var/luximmo-media"
assets="$root/var/luximmo-assets"
if [ -n "$1" ]; then
mkdir -p "$assets"
node scripts/build-luximmo-poster.mjs "$1/hero.webm" "$assets/hero-poster.png" 0.4
"$php" scripts/build-luximmo-assets.php "$1" "$assets" "$assets/hero-poster.png"
rm -f "$assets/hero-poster.png"
fi
if [ ! -f "$assets/hero-poster.webp" ] || [ ! -f "$assets/logo-original.svg" ] || [ ! -f "$assets/hero.webm" ]; then
echo "Bilder fehlen: erst mit dem assets-Ordner des Pakets als Argument aufrufen." >&2
exit 1
fi
rm -f "$root/var/luximmo.db"
rm -rf "$root/var/luximmo-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-luximmo.php "$assets"
# Instrument Sans vom eigenen Server; der Abruf ergaenzt fonts.css nur.
if [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
rm -f "$root/var/luximmo-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/luximmo-paket.zip"
+1 -1
View File
@@ -8,7 +8,7 @@ import net from 'node:net';
const php = process.env.PHP_BINARY || 'php'; const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); 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 }); 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 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 port = probe.address().port; await new Promise(resolve => probe.close(resolve));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -3
View File
@@ -9,6 +9,9 @@ final class DocumentValidator
{ {
private bool $writing = false; 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) public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
{ {
} }
@@ -16,7 +19,7 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */ /** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem']; public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */ /** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp']; public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */ /** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
public const BUTTON_DEFAULT_ICON = 'arrow-up-right'; public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge']; public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
@@ -59,6 +62,24 @@ final class DocumentValidator
* lässt unpassende Eigenschaften ohnehin nicht durch. * lässt unpassende Eigenschaften ohnehin nicht durch.
*/ */
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void 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; $this->writing = $writing;
$schemaVersion = $document['schemaVersion'] ?? null; $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.'); $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 { try {
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing); $this->check(Translations::apply($document, $locale), $requireHeading, $this->writing);
} catch (InvalidDocument $error) { } catch (InvalidDocument $error) {
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $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. // and is answered in the controller, not here.
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.'); $this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own)); $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'])) { if (isset($block['style'])) {
// Free styling: any property of StyleSchema, per device, with an optional hover value. // Free styling: any property of StyleSchema, per device, with an optional hover value.
+5 -2
View File
@@ -707,7 +707,10 @@ final class FormRepository
'id' => $id, 'form_id' => $formId, 'id' => $id, 'form_id' => $formId,
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'sender_hash' => $senderHash, 'created_at' => $now, 'sender_hash' => $senderHash, 'created_at' => $now,
'consent_at' => $consent ? $now : null, 'delivered' => false, 'consent_at' => $consent ? $now : null,
// Als Zahl: ohne Typangabe wird false als leere Zeichenkette gebunden, und MySQL im
// Strict-Modus lehnt die für eine Zahlenspalte ab. SQLite nimmt beides.
'delivered' => 0,
]); ]);
return $id; return $id;
@@ -715,7 +718,7 @@ final class FormRepository
public function markDelivered(string $submissionId): void public function markDelivered(string $submissionId): void
{ {
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]); $this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [1, $submissionId]);
} }
/** /**
+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. */ /** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
public const PER_PAGE = 24; 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. * 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 // 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 // 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. // 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)); $targetHeight = max(1, (int) round($height * $targetWidth / $width));
$target = imagecreatetruecolor($targetWidth, $targetHeight); $target = imagecreatetruecolor($targetWidth, $targetHeight);
try { try {
@@ -174,7 +200,7 @@ final class MediaLibrary
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp'; $path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
$paths[] = $path; $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.'); throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
} }
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)]; $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 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)) { 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;
}
}
+6
View File
@@ -190,6 +190,12 @@ final class PageRepository
); );
} }
/** Ob eine Seite eine veröffentlichte Fassung trägt - auch wenn sie im Papierkorb liegt. */
public function hasPublication(string $pageId): bool
{
return false !== $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND website_id = ? AND published_revision_id IS NOT NULL', [$pageId, 'default']);
}
public function trashPage(string $pageId, int $expectedVersion): int public function trashPage(string $pageId, int $expectedVersion): int
{ {
return $this->db->transactional(function () use ($pageId, $expectedVersion): int { return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
+27 -16
View File
@@ -239,26 +239,37 @@ final class PostRepository
[$pageId, 'category'], [$pageId, 'category'],
); );
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier. // Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts']; // Geholt wird erst nur die Reihenfolge - Kennungen, keine Dokumente - und danach genau
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse. // das, was gezeigt wird: die zwei Nachbarn. Vorher lud jeder Aufruf eines Beitrags bis zu
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist. // 500 veröffentlichte Fassungen mit vollem Dokument, um zwei Titel zu lesen, und ab dem
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId])); // 501. Beitrag einer Kategorie gab es gar keine Nachbarn mehr.
$index = null; $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";
foreach ($all as $position => $post) { $params = ['default', $locale];
if ($post['href'] === $own) { $join = '';
$index = $position; if ('' !== $termId) {
break; $join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
array_unshift($params, $termId);
} }
} $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);
if (null === $index) { $index = array_search($pageId, $order, true);
if (false === $index) {
return ['previous' => null, 'next' => null]; return ['previous' => null, 'next' => null];
} }
return [
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht. // „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
'previous' => $all[$index - 1] ?? null, $wanted = array_filter(['previous' => $order[$index - 1] ?? null, 'next' => $order[$index + 1] ?? null]);
'next' => $all[$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 ['previous' => $post($wanted['previous'] ?? null), 'next' => $post($wanted['next'] ?? null)];
} }
/** /**
+82 -2
View File
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
*/ */
final class SiteSettings final class SiteSettings
{ {
/** Die Unterarten von schema.org/LocalBusiness, die sich in den Einstellungen wählen lassen. */
public const BUSINESS_TYPES = [
'LocalBusiness' => 'Allgemein (LocalBusiness)',
'RealEstateAgent' => 'Immobilien (RealEstateAgent)',
'HomeAndConstructionBusiness' => 'Handwerk und Bau allgemein',
'Electrician' => 'Elektriker',
'Plumber' => 'Sanitär',
'HVACBusiness' => 'Heizung und Klima',
'HousePainter' => 'Maler',
'RoofingContractor' => 'Dachdecker',
'GeneralContractor' => 'Bauunternehmen',
'AccountingService' => 'Steuern und Buchhaltung',
'FinancialService' => 'Finanzdienstleistung',
'LegalService' => 'Rechtsberatung',
'MedicalBusiness' => 'Medizin und Pflege',
'TravelAgency' => 'Reisebüro',
'Restaurant' => 'Restaurant',
'Store' => 'Geschäft',
];
public const DEFAULTS = [ public const DEFAULTS = [
'legalName' => '', 'legalName' => '',
'description' => '', 'description' => '',
@@ -32,6 +52,12 @@ final class SiteSettings
'openingHours' => [], 'openingHours' => [],
'areaServed' => [], 'areaServed' => [],
'localBusiness' => false, 'localBusiness' => false,
// Genauer als „lokales Unternehmen“, dazu der Standort und ein Logo mit eigener Adresse:
// alles nur in den strukturierten Daten, leer bleibt es dort weg (ADR 126).
'businessType' => 'LocalBusiness',
'latitude' => '',
'longitude' => '',
'organisationLogoMediaId' => '',
'llmsTxt' => false, 'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und // Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst. // nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
@@ -193,7 +219,17 @@ final class SiteSettings
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])]; 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; $values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page. // Assigned separately, so saving company data cannot clear the error page.
@@ -240,6 +276,19 @@ final class SiteSettings
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) { if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.'); throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
} }
$values['businessType'] = (string) ($input['businessType'] ?? 'LocalBusiness');
if (!isset(self::BUSINESS_TYPES[$values['businessType']])) {
throw new InvalidPage('Diese Art von Unternehmen steht nicht zur Auswahl.');
}
$values['latitude'] = $this->coordinate($input['latitude'] ?? '', 90, 'Breitengrad');
$values['longitude'] = $this->coordinate($input['longitude'] ?? '', 180, 'Längengrad');
if (('' === $values['latitude']) !== ('' === $values['longitude'])) {
throw new InvalidPage('Für den Standort braucht es Breiten- und Längengrad, oder keinen von beiden.');
}
$values['organisationLogoMediaId'] = trim((string) ($input['organisationLogoMediaId'] ?? ''));
if ('' !== $values['organisationLogoMediaId'] && (1 !== preg_match('/^[a-f0-9]{32}$/D', $values['organisationLogoMediaId']) || null === $this->media->find($values['organisationLogoMediaId']))) {
throw new InvalidPage('Das gewählte Logo gibt es nicht (mehr).');
}
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false); $values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false); $values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false); $values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
@@ -373,10 +422,18 @@ final class SiteSettings
} }
$this->loaded = null; $this->loaded = null;
$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 = ?', [ $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default', 'default',
]); ]);
});
} }
/** Change only the 404 assignment, preserving settings saved in other forms. */ /** Change only the 404 assignment, preserving settings saved in other forms. */
@@ -512,6 +569,20 @@ final class SiteSettings
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath; return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
} }
/** Eine Koordinate als Dezimalzahl; das Komma der deutschen Schreibweise wird zum Punkt. */
private function coordinate(mixed $input, int $limit, string $label): string
{
$value = str_replace(',', '.', trim((string) $input));
if ('' === $value) {
return '';
}
if (1 !== preg_match('/^-?\d{1,3}(?:\.\d{1,7})?$/D', $value) || abs((float) $value) > $limit) {
throw new InvalidPage(sprintf('Der %s ist keine gültige Koordinate, zum Beispiel 48.79381.', $label));
}
return $value;
}
/** /**
* Structured data for the organisation, built from the maintained values only. Returns null * Structured data for the organisation, built from the maintained values only. Returns null
* while nothing is maintained: an empty Organization block would be noise, not data. * while nothing is maintained: an empty Organization block would be noise, not data.
@@ -522,9 +593,10 @@ final class SiteSettings
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) { if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
return null; return null;
} }
$lokal = $settings['localBusiness'] && $hasAddress;
$data = [ $data = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization', '@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName, 'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
'url' => $origin.'/', 'url' => $origin.'/',
]; ];
@@ -542,6 +614,14 @@ final class SiteSettings
'addressCountry' => $settings['country'], 'addressCountry' => $settings['country'],
], static fn (string $value): bool => '' !== $value); ], static fn (string $value): bool => '' !== $value);
} }
if ($lokal && '' !== ($settings['latitude'] ?? '') && '' !== ($settings['longitude'] ?? '')) {
$data['geo'] = ['@type' => 'GeoCoordinates', 'latitude' => (float) $settings['latitude'], 'longitude' => (float) $settings['longitude']];
}
// Eine gelöschte Datei darf keine tote Adresse in die Daten bringen.
$logo = '' !== ($settings['organisationLogoMediaId'] ?? '') ? $this->media->find($settings['organisationLogoMediaId']) : null;
if (null !== $logo && [] !== $logo['variants']) {
$data['logo'] = $origin.end($logo['variants'])['src'];
}
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch // Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste. // das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])])); $sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
+5 -1
View File
@@ -488,7 +488,11 @@ final class StyleCompiler
*/ */
public static function variantFor(array $variants, string $device): array 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]; $chosen = $variants[0];
foreach ($variants as $variant) { foreach ($variants as $variant) {
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) { if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
+16 -1
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 = [ $theme = [
'colors' => $this->colors($colors), 'colors' => $this->colors($colors),
@@ -132,10 +139,18 @@ final class Theme
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.'); throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
} }
} }
$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 = ?', [ $this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default', 'default',
]); ]);
});
// After the write, never before: saving reads the theme again, and an early reset // 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. // would only refill the memo with the state that is about to be replaced.
$this->loaded = null; $this->loaded = null;
+8 -1
View File
@@ -332,13 +332,20 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
} }
// Der Papierkorb lässt die veröffentlichte Fassung stehen: eine Seite, die online war, ist
// nach dem Wiederherstellen sofort wieder online. Das entscheidet, wer veröffentlichen
// darf (ADR 116) - wie beim Weg in den Papierkorb.
$live = $this->pages->hasPublication($id);
if ($live && !$this->isGranted('ROLE_PUBLISHER')) {
return $this->back('Diese Seite war veröffentlicht und wäre nach dem Wiederherstellen sofort wieder öffentlich. Das macht, wer veröffentlichen darf.');
}
try { try {
$this->pages->restorePage($id); $this->pages->restorePage($id);
} catch (InvalidPage $e) { } catch (InvalidPage $e) {
return $this->back($e->getMessage()); return $this->back($e->getMessage());
} }
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success'); return $this->back($live ? 'Die Seite ist zurück und wieder öffentlich.' : 'Die Seite ist zurück. Sie ist ein Entwurf, bis sie veröffentlicht wird.', 'success');
} }
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])] #[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
+18 -4
View File
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\Turnstile; use App\Content\Turnstile;
use App\Ops\MailCheck; use App\Ops\MailCheck;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
private string $secret, private string $secret,
private string $siteOrigin, private string $siteOrigin,
private Languages $languages, private Languages $languages,
private PluginRuntime $plugins,
) { ) {
} }
@@ -41,7 +43,12 @@ final class FormController extends AbstractController
{ {
$form = $this->forms->findBySlug($slug); $form = $this->forms->findBySlug($slug);
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath(); $path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
$page = $this->pages->findPublished($path); // Ein Archiv (Beiträge, Kategorie, Schlagwort) ist keine gespeicherte Seite, trägt aber Kopf
// und Fuß wie jede andere - und damit oft das Kontaktformular. Ohne diese Ausnahme endete
// das Absenden dort in einer 404, und die Anfrage war weg.
$archive = 1 === preg_match(PageController::ARCHIVE_PATH, $path);
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
$page = $archive ? ['id' => null, 'path' => $path] : ($this->pages->findPublished($path) ?? $this->plugins->page('/'.$path));
if (null === $form || null === $page) { if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.'); throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
} }
@@ -62,7 +69,8 @@ final class FormController extends AbstractController
$language = $request->request->getString('language'); $language = $request->request->getString('language');
$translatedPath = null; $translatedPath = null;
if ($this->languages->isExtra($language)) { if ($this->languages->isExtra($language)) {
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null; // Archive heißen in jeder Sprache gleich und stehen nur hinter der Sprache.
$translatedPath = $archive ? $path : ($this->pages->translatedPaths($page['id'])[$language] ?? null);
} }
$language = null === $translatedPath ? null : $language; $language = null === $translatedPath ? null : $language;
@@ -77,8 +85,11 @@ final class FormController extends AbstractController
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath)); return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
} }
if (!$silentlyDropped) { if (!$silentlyDropped) {
// Angaben eines Plugins zur Seite, von der die Anfrage kommt: Name => Text.
$extra = array_filter((array) $this->plugins->applyFilters('form.context', [], $form, $path), 'is_string');
$checked['values'] += $extra;
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash); $submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
if ($this->deliver($form, $checked['values'])) { if ($this->deliver($form, $checked['values'], $extra)) {
$this->forms->markDelivered($submission); $this->forms->markDelivered($submission);
} }
} }
@@ -101,9 +112,12 @@ final class FormController extends AbstractController
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS; return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
} }
private function deliver(array $form, array $values): bool private function deliver(array $form, array $values, array $extra = []): bool
{ {
$lines = []; $lines = [];
foreach ($extra as $name => $text) {
$lines[] = $name.': '.$text;
}
foreach ($form['fields'] as $field) { foreach ($form['fields'] as $field) {
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg. // Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
if ('step' === $field['type']) { if ('step' === $field['type']) {
+5 -1
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\DocumentLibrary; use App\Content\DocumentLibrary;
use App\Content\MediaLibrary; use App\Content\MediaLibrary;
use App\Content\MediaMirror;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\VideoLibrary; use App\Content\VideoLibrary;
use App\Domain\StaleWrite; use App\Domain\StaleWrite;
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController 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 public function serve(string $id, int $width): Response
{ {
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException(); $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'"]); 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'"]);
} }
+89 -8
View File
@@ -22,6 +22,7 @@ use App\Content\Theme;
use App\Content\Translations; use App\Content\Translations;
use App\Content\Turnstile; use App\Content\Turnstile;
use App\Plugin\PluginRegistry; use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -29,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController final class PageController extends AbstractController
{ {
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages) public function __construct(private PageRepository $pages, private PluginRuntime $runtime, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
{ {
} }
@@ -48,6 +49,9 @@ final class PageController extends AbstractController
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them. // the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}'; private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
/** Die Adressen der Archive, ohne Sprache davor: Beiträge, eine Kategorie, ein Schlagwort. */
public const ARCHIVE_PATH = '#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D';
#[Route('/', name: 'home', methods: ['GET'])] #[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])] #[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response public function page(Request $request, string $path = ''): Response
@@ -59,6 +63,11 @@ final class PageController extends AbstractController
if ('' !== $path && $this->languages->isExtra($segments[0])) { if ('' !== $path && $this->languages->isExtra($segments[0])) {
return $this->translatedPage($request, $segments[0], $segments[1] ?? ''); return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
} }
// Nur der Formularversand kommt mit einer Archivadresse hier an (die Route selbst schließt
// sie aus): er zeigt das Archiv mit den Meldungen des Formulars noch einmal.
if (1 === preg_match(self::ARCHIVE_PATH, $path, $archiv)) {
return $this->renderArchive($request, str_starts_with($path, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
}
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor. // Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) { if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
return $this->notFound($request); return $this->notFound($request);
@@ -78,6 +87,10 @@ final class PageController extends AbstractController
if (null !== $route) { if (null !== $route) {
return $this->pluginRoute($request, $route); return $this->pluginRoute($request, $route);
} }
$pluginPage = $this->runtime->page('/'.$path);
if (null !== $pluginPage) {
return $this->renderPage($request, $pluginPage, $path, $pluginPage['status']);
}
$redirect = $this->redirects->find($path); $redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) { if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301)); return $this->shell($this->redirect('/'.$redirect['to'], 301));
@@ -106,7 +119,7 @@ final class PageController extends AbstractController
return $this->shell($this->redirect('/'.$locale, 301)); return $this->shell($this->redirect('/'.$locale, 301));
} }
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache. // Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) { if (1 === preg_match(self::ARCHIVE_PATH, $rest, $archiv)) {
$this->translation = ['locale' => $locale, 'map' => $map]; $this->translation = ['locale' => $locale, 'map' => $map];
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null)); return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
@@ -310,6 +323,10 @@ final class PageController extends AbstractController
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine); $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)]; $documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider')); $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)); $needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
@@ -928,7 +945,9 @@ final class PageController extends AbstractController
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine // Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
// eigene Vorlage, die von alledem nur die Palette kannte. // eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [ $kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]], // Ab Schemaversion 12 wird der Text einer Überschrift als Markup gedruckt. Der Name
// einer Kategorie ist aber freier Text, den niemand gefiltert hat - also maskieren.
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => htmlspecialchars($heading, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]], ['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
]; ];
if (null !== $term) { if (null !== $term) {
@@ -1027,6 +1046,59 @@ final class PageController extends AbstractController
return $response; 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. * The first image of the page, in the order it is rendered.
* *
@@ -1054,24 +1126,33 @@ final class PageController extends AbstractController
// obere. Sonst der erste Abschnitt des Dokuments. // obere. Sonst der erste Abschnitt des Dokuments.
foreach ([$header, $document] as $kandidat) { foreach ([$header, $document] as $kandidat) {
$block = ($kandidat['blocks'] ?? [])[0] ?? null; $block = ($kandidat['blocks'] ?? [])[0] ?? null;
$id = $block['style']['desktop']['backgroundImage'] ?? null; // Je Gerät das Bild, das die Regel dort wirklich nimmt: Tablet erbt vom Desktop, das
if (!is_string($id) || '' === $id) { // 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; continue;
} }
$item = $this->media->find($id); $item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) { if (null === $item || [] === ($item['variants'] ?? [])) {
continue; continue;
} }
$verweise = [];
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
$verweise[] = [ $verweise[] = [
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'], 'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
'media' => $media, 'media' => $media,
]; ];
} }
if ([] !== $verweise) {
return $verweise; return $verweise;
} }
}
return []; return [];
} }
+48 -4
View File
@@ -2,7 +2,9 @@
namespace App\Controller; namespace App\Controller;
use App\Ops\Scheduler;
use App\Plugin\PluginRegistry; use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/ */
final class PluginAdminController extends AbstractController final class PluginAdminController extends AbstractController
{ {
public function __construct(private PluginRegistry $plugins) public function __construct(private PluginRegistry $plugins, private PluginRuntime $runtime, private Scheduler $scheduler)
{ {
} }
@@ -24,7 +26,12 @@ final class PluginAdminController extends AbstractController
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator. // Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory()]); $admin = [];
foreach ($this->plugins->all() as $name => $plugin) {
$admin[$name] = $this->runtime->admin($name);
}
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory(), 'admin' => $admin, 'runtimeErrors' => $this->runtime->errors()]);
} }
#[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])] #[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])]
@@ -36,11 +43,13 @@ final class PluginAdminController extends AbstractController
} }
try { try {
if ('an' === $state) { if ('an' === $state) {
$this->plugins->activate($name); $this->runtime->activate($name);
$this->scheduler->pluginsChanged();
return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success'); return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success');
} }
$this->plugins->deactivate($name); $this->runtime->deactivate($name);
$this->scheduler->pluginsChanged();
return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success'); return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success');
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
@@ -64,6 +73,41 @@ final class PluginAdminController extends AbstractController
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success'); return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
} }
#[Route('/%kant.admin_path%/plugins/{name}/seite/{key}', name: 'admin_plugin_page', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['GET'])]
public function page(string $name, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$admin = $this->runtime->admin($name);
$page = $admin['pages'][$key] ?? throw $this->createNotFoundException();
return $this->render('admin/plugin-page.html.twig', ['plugin' => $this->plugins->all()[$name], 'page' => $page, 'actions' => $admin['actions'], 'data' => ($page['callback'])()]);
}
#[Route('/%kant.admin_path%/plugins/{name}/aktion/{key}', name: 'admin_plugin_action', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['POST'])]
public function action(Request $request, string $name, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$admin = $this->runtime->admin($name);
$action = $admin['actions'][$key] ?? throw $this->createNotFoundException();
try {
if (is_string($action['handler'])) {
$result = $this->scheduler->runNow(PluginRuntime::taskName($name, $action['handler']));
[$type, $message] = null === $result ? ['error', 'Die Aufgabe läuft gerade schon.'] : ['ok' === $result['status'] ? 'success' : 'error', $result['message']];
} else {
[$type, $message] = ['success', (string) ($action['handler'])()];
}
} catch (\Throwable $e) {
[$type, $message] = ['error', $e->getMessage()];
}
$this->addFlash($type, $message);
$back = $request->request->getString('zurueck');
return isset($admin['pages'][$back]) ? $this->redirectToRoute('admin_plugin_page', ['name' => $name, 'key' => $back]) : $this->redirectToRoute('admin_plugins');
}
private function back(string $message, string $type = 'error'): Response private function back(string $message, string $type = 'error'): Response
{ {
$this->addFlash($type, $message); $this->addFlash($type, $message);
+3 -1
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/ */
final class SeoController extends AbstractController final class SeoController extends AbstractController
{ {
public function __construct( public function __construct(private \App\Plugin\PluginRuntime $plugins,
private Connection $db, private Connection $db,
private SiteSettings $settings, private SiteSettings $settings,
private PostRepository $posts, private PostRepository $posts,
@@ -74,6 +74,8 @@ final class SeoController extends AbstractController
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null]; $entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
} }
$entries = $this->plugins->applyFilters('sitemap.entries', $entries, $origin);
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries])); return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
} }
+5 -2
View File
@@ -57,6 +57,8 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [ return $this->render('admin/settings.html.twig', [
'settings' => $settings, 'settings' => $settings,
'settingsStamp' => $this->settings->stamp(),
'businessTypes' => SiteSettings::BUSINESS_TYPES,
'legalSelection' => $legal, 'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(), 'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)), 'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
@@ -123,8 +125,9 @@ final class SettingsController extends AbstractController
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
} }
try { try {
$this->settings->save($request->request->all()); // Ohne Kennung (ein Formular von vor dem Update) wird nicht geprüft, sondern gespeichert.
} catch (InvalidPage $e) { $this->settings->save($request->request->all(), $request->request->getString('expected') ?: null);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage()); return $this->back($e->getMessage());
} }
+10
View File
@@ -177,6 +177,9 @@ final class SystemController extends AbstractController
@set_time_limit(0); @set_time_limit(0);
if ($request->request->getBoolean('finish')) { if ($request->request->getBoolean('finish')) {
$ergebnis = $this->archive->finish($this->installation); $ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']); $request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok'] $this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Das Update ist fertig. Die Website ist wieder erreichbar.' ? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
@@ -271,6 +274,10 @@ final class SystemController extends AbstractController
} }
@set_time_limit(0); @set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation, $this->pruefung); $ergebnis = $this->archive->finish($this->installation, $this->pruefung);
if ($ergebnis['laeuft'] ?? false) {
// Der andere Abschluss schreibt den Stand fort; die Systemseite fragt ihn weiter ab.
return new JsonResponse(['ok' => true, 'meldung' => ArchiveUpdate::LAEUFT_BEREITS, 'status' => $this->archive->status()]);
}
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]); $this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
} }
@@ -311,6 +318,9 @@ final class SystemController extends AbstractController
} }
@set_time_limit(0); @set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation); $ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']); $request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok'] $this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung']) ? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
+3 -2
View File
@@ -27,6 +27,7 @@ final class ThemeController extends AbstractController
return $this->render('admin/theme.html.twig', [ return $this->render('admin/theme.html.twig', [
'theme' => $theme, 'theme' => $theme,
'themeStamp' => $this->theme->stamp(),
'fallbacks' => Theme::FALLBACKS, 'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS, 'weights' => Theme::WEIGHTS,
'buttonOptions' => ButtonDesign::OPTIONS, 'buttonOptions' => ButtonDesign::OPTIONS,
@@ -93,8 +94,8 @@ final class ThemeController extends AbstractController
$fonts[] = $row; $fonts[] = $row;
} }
try { try {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null); $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 $e) { } catch (InvalidPage|\App\Domain\StaleWrite $e) {
$this->addFlash('error', $e->getMessage()); $this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_theme'); return $this->redirectToRoute('admin_theme');
@@ -62,7 +62,7 @@ final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
} }
@ignore_user_abort(true); @ignore_user_abort(true);
@set_time_limit(0); @set_time_limit(0);
$this->archive->finish($this->installation, $this->pruefung); $this->archive->finish($this->installation, $this->pruefung, true);
} catch (\Throwable) { } catch (\Throwable) {
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an. // Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
} finally { } finally {
+38 -10
View File
@@ -23,6 +23,8 @@ namespace App\Ops;
*/ */
final class ArchiveUpdate final class ArchiveUpdate
{ {
public const LAEUFT_BEREITS = 'Der Abschluss läuft bereits in einer anderen Anfrage. Diese Seite in einem Moment neu laden.';
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */ /** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION']; private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
@@ -193,16 +195,7 @@ final class ArchiveUpdate
return ['ok' => false, 'steps' => $schritte]; 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)); $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];
}
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip'; $archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen // Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
@@ -248,6 +241,20 @@ final class ArchiveUpdate
$this->updater->maintenance(true); $this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.'); $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'; $alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true); @mkdir($alt, 0o775, true);
$ersetzt = 0; $ersetzt = 0;
@@ -363,7 +370,28 @@ final class ArchiveUpdate
* gibt null zurück oder den Grund, warum sie nicht * gibt null zurück oder den Grund, warum sie nicht
* läuft - dann wird zurückgeschaltet * läuft - dann wird zurückgeschaltet
*/ */
public function finish(Installation $installation, ?callable $pruefen = null): array public function finish(Installation $installation, ?callable $pruefen = null, bool $gesperrt = false): array
{
// Nur ein Abschluss zur selben Zeit. Die Zwischenseite ruft ihn von selbst auf und bietet
// daneben einen Link an: Zwei Abschlüsse nebeneinander ließen den zweiten an der bereits
// laufenden Migration scheitern - und der schaltete dann zurück, Code und Datenbank,
// obwohl der erste gelungen war. $gesperrt: der Aufrufer hält die Sperre schon.
$sperre = $gesperrt ? null : $this->updater->lock();
if (!$gesperrt && null === $sperre) {
return ['ok' => false, 'laeuft' => true, 'steps' => [['step' => 'Abschluss', 'ok' => true, 'detail' => self::LAEUFT_BEREITS]]];
}
try {
return $this->abschliessen($installation, $pruefen);
} finally {
if (null !== $sperre) {
flock($sperre, LOCK_UN);
fclose($sperre);
}
}
}
/** @param (callable(): ?string)|null $pruefen */
private function abschliessen(Installation $installation, ?callable $pruefen): array
{ {
$schritte = []; $schritte = [];
$offen = $this->pending(); $offen = $this->pending();
+70 -8
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)); 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); $database = $this->copyDatabase($directory);
$media = $this->copyMedia($directory.'/medien'); $media = $this->copyMedia($directory.'/medien');
$manifest = [ $manifest = [
@@ -88,7 +103,10 @@ final class Backup
// Not in here, and it is worth saying so where somebody reads it. // Not in here, and it is worth saying so where somebody reads it.
'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'], '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]; return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
} }
@@ -116,14 +134,24 @@ final class Backup
return $backups; 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 public function rotate(int $keep = self::KEEP): array
{ {
$removed = []; $removed = [];
foreach (array_slice($this->all(), max(1, $keep)) as $backup) { $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']); $this->remove($backup['path']);
$removed[] = $backup['name']; $removed[] = $backup['name'];
} }
}
return $removed; return $removed;
} }
@@ -184,7 +212,21 @@ final class Backup
} }
} else { } else {
$this->db->close(); $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.')) { if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false]; return ['steps' => $steps, 'ok' => false];
} }
@@ -194,6 +236,12 @@ final class Backup
// by the very operation that is meant to prevent loss. // by the very operation that is meant to prevent loss.
$restored = $this->restoreMedia($path.'/medien'); $restored = $this->restoreMedia($path.'/medien');
$step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored)); $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]; return ['steps' => $steps, 'ok' => true];
} }
@@ -303,10 +351,17 @@ final class Backup
$count = 0; $count = 0;
$bytes = 0; $bytes = 0;
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) { foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $target.'/'.basename($file))) { if (!is_file($file)) {
++$count; continue;
$bytes += (int) filesize($file);
} }
$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]; return ['count' => $count, 'bytes' => $bytes];
@@ -350,7 +405,14 @@ final class Backup
return false; 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 private function size(string $path): int
+14 -2
View File
@@ -62,6 +62,7 @@ final class Scheduler
private Connection $db, private Connection $db,
private iterable $tasks, private iterable $tasks,
private string $projectDir, private string $projectDir,
private ?\App\Plugin\PluginRuntime $plugins = null,
) { ) {
} }
@@ -268,6 +269,14 @@ final class Scheduler
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und * @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
* zwischen hochgeladenem Code und ausgefuehrter Migration * zwischen hochgeladenem Code und ausgefuehrter Migration
*/ */
/** Nach dem Ein- oder Ausschalten eines Plugins: Der Plan kennt dessen Aufgaben sofort. */
public function pluginsChanged(): void
{
$this->registry = null;
$this->sync();
$this->refreshState();
}
public function sync(?\DateTimeImmutable $now = null): bool public function sync(?\DateTimeImmutable $now = null): bool
{ {
$now ??= self::now(); $now ??= self::now();
@@ -282,7 +291,7 @@ final class Scheduler
$this->db->insert('scheduled_task', [ $this->db->insert('scheduled_task', [
'name' => $name, 'name' => $name,
'interval_seconds' => $task->interval(), 'interval_seconds' => $task->interval(),
'enabled' => true, 'enabled' => 1,
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'), 'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
'last_message' => '', 'last_message' => '',
'last_duration_ms' => 0, 'last_duration_ms' => 0,
@@ -348,7 +357,7 @@ final class Scheduler
public function enable(string $name, bool $on): void public function enable(string $name, bool $on): void
{ {
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]); $this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [(int) $on, $name]);
$this->refreshState(); $this->refreshState();
} }
@@ -396,6 +405,9 @@ final class Scheduler
foreach ($this->tasks as $task) { foreach ($this->tasks as $task) {
$registry[$task->name()] = $task; $registry[$task->name()] = $task;
} }
foreach ($this->plugins?->tasks() ?? [] as $task) {
$registry[$task->name()] = $task;
}
ksort($registry); ksort($registry);
return $this->registry = $registry; return $this->registry = $registry;
+51 -12
View File
@@ -3,6 +3,7 @@
namespace App\Ops; namespace App\Ops;
use App\Content\DocumentValidator; use App\Content\DocumentValidator;
use App\Content\MediaMirror;
use App\Domain\InvalidDocument; use App\Domain\InvalidDocument;
use App\Domain\InvalidPage; use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
@@ -49,6 +50,7 @@ final class SitePackage
private Updater $updater, private Updater $updater,
private string $root, private string $root,
private string $mediaDirectory, private string $mediaDirectory,
private ?MediaMirror $mirror = null,
) { ) {
} }
@@ -207,19 +209,36 @@ final class SitePackage
return $fail(); 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); $this->updater->maintenance(true);
try {
try { try {
$media = $this->extractMedia($zip); $media = $this->extractMedia($zip);
$this->write($tables, $withUsers); $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) { } catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.'); $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 { } finally {
if (!$inWartung) {
$this->updater->maintenance(false); $this->updater->maintenance(false);
}
if (isset($media)) { if (isset($media)) {
$this->remove($media); $this->remove($media);
} }
@@ -435,25 +454,45 @@ final class SitePackage
return $target; 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 private function replaceMedia(string $source): int
{ {
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) { if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.'); 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) { 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); @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 private function summary(array $tables, bool $withUsers): string
+7 -2
View File
@@ -2,6 +2,7 @@
namespace App\Ops\Tasks; namespace App\Ops\Tasks;
use App\Content\MediaMirror;
use App\Ops\ArchiveUpdate; use App\Ops\ArchiveUpdate;
use App\Ops\Scheduler; use App\Ops\Scheduler;
use App\Ops\Updater; use App\Ops\Updater;
@@ -26,7 +27,7 @@ final class CleanupTask implements Task
/** So viele Zeilen bleiben in den Protokollen stehen. */ /** So viele Zeilen bleiben in den Protokollen stehen. */
private const KEEP_LINES = 200; 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; $limit = time() - self::KEEP_DAYS * 86400;
return sprintf( $bericht = sprintf(
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.', '%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
$this->sessions($limit), $this->sessions($limit),
$this->updateFolders($limit), $this->updateFolders($limit),
$this->logs(), $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. */ /** 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']);
}
}
+248
View File
@@ -0,0 +1,248 @@
<?php
namespace App\Plugin;
use App\Content\MediaLibrary;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Was die plugin.php eines Plugins vom Kern bekommt (ADR 127).
*
* Eine Instanz je Plugin. Das Plugin meldet hier an, was es kann - Hooks, Aufgaben im Takt, Seiten,
* Bildschirme der Verwaltung -, und bekommt seine Einstellungen, die Datenbank und die Mediathek.
* Der Kern übernimmt die Anmeldungen erst, wenn die plugin.php ohne Fehler durchgelaufen ist.
*
* Plugin-Code läuft mit den vollen Rechten der Anwendung, wie bei WordPress. Das ist kein
* Sandkasten und wird nicht so genannt: Ein Plugin installiert nur, wer dem Code traut.
*/
final class PluginApi
{
/** Größte Antwort, die fetch() annimmt. */
private const MAX_BYTES = 16_000_000;
/** @var array{install: list<callable>, deactivate: list<callable>, actions: array<string, list<array{int, callable}>>, filters: array<string, list<array{int, callable}>>, tasks: array<string, array>, pages: list<array>, data: array<string, callable>, adminPages: array<string, array>, adminActions: array<string, array>} */
public array $registered = ['install' => [], 'deactivate' => [], 'actions' => [], 'filters' => [], 'tasks' => [], 'pages' => [], 'data' => [], 'adminPages' => [], 'adminActions' => []];
public function __construct(
private string $name,
private string $directory,
private PluginRegistry $plugins,
private Connection $db,
private MediaLibrary $media,
) {
}
public function name(): string
{
return $this->name;
}
/** Der Ordner des Plugins, ohne Schrägstrich am Ende. */
public function directory(): string
{
return $this->directory;
}
/** Die geprüften Einstellungen aus der plugin.json, jede vorhanden. */
public function settings(): array
{
return $this->plugins->settingsOf($this->name);
}
public function db(): Connection
{
return $this->db;
}
/** Der Name einer eigenen Tabelle: plugin_<name>_<kurz>. Andere Tabellen gehören dem Kern. */
public function table(string $short): string
{
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,30}$/D', $short)) {
throw new \InvalidArgumentException('Ein Tabellenname besteht aus Kleinbuchstaben, Ziffern und Unterstrichen.');
}
return 'plugin_'.$this->name.'_'.$short;
}
public function media(): MediaLibrary
{
return $this->media;
}
// ---------------------------------------------------------------- Lebenszyklus
/**
* Läuft beim Einschalten und wenn sich die Fassung des Plugins geändert hat - muss also
* wiederholbar sein (CREATE TABLE IF NOT EXISTS). Wirft es, bleibt das Plugin aus.
*/
public function onInstall(callable $callback): void
{
$this->registered['install'][] = $callback;
}
/** Läuft beim Ausschalten. Eigene Tabellen bleiben stehen, wie die Bausteine in den Seiten. */
public function onDeactivate(callable $callback): void
{
$this->registered['deactivate'][] = $callback;
}
// ---------------------------------------------------------------- Hooks
/** Reagiert auf ein Ereignis des Kerns oder eines anderen Plugins. Kleinere Priorität zuerst. */
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['actions'][$hook][] = [$priority, $callback];
}
/** Verändert einen Wert: Der Callback bekommt ihn als erstes Argument und gibt ihn zurück. */
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['filters'][$hook][] = [$priority, $callback];
}
// ---------------------------------------------------------------- Anmeldungen
/**
* Eine Aufgabe im Takt. Sie steht unter „Aufgaben“ im Plan des Kerns; Fehler soll sie werfen,
* ihre Rückgabe ist der Bericht. Die Regeln stehen in App\Ops\Tasks\Task.
*
* @param callable(): string $callback
*/
public function addTask(string $key, string $label, string $explanation, int $interval, callable $callback): void
{
$this->key($key);
if ($interval < 60) {
throw new \InvalidArgumentException('Eine Aufgabe läuft höchstens jede Minute.');
}
$this->registered['tasks'][$key] = ['key' => $key, 'label' => $label, 'explanation' => $explanation, 'interval' => $interval, 'callback' => $callback];
}
/**
* Eine öffentliche Seite im Rahmen der Website, zum Beispiel `/objekte/{slug}`.
*
* Der Callback bekommt die Platzhalter und gibt null zurück (dann 404) oder ein Array mit
* `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image`.
* Eine veröffentlichte Seite mit derselben Adresse geht vor.
*
* @param callable(array<string, string>): ?array $callback
*/
public function addPage(string $pattern, callable $callback): void
{
if (1 !== preg_match('~^(?:/(?:[a-z0-9]+(?:-[a-z0-9]+)*|\{[a-z]+\})){1,4}$~D', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ hat nicht die Form /name/{platzhalter}.', $pattern));
}
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ gehört dem Kern.', $pattern));
}
$regex = '~^'.preg_replace('~\\\\\{([a-z]+)\\\\\}~', '(?P<$1>[a-z0-9]+(?:-[a-z0-9]+)*)', preg_quote($pattern, '~')).'$~D';
$this->registered['pages'][] = ['pattern' => $pattern, 'regex' => $regex, 'callback' => $callback];
}
/**
* Daten für die Twig-Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`.
*
* @param callable(array): mixed $callback
*/
public function addData(string $key, callable $callback): void
{
$this->key($key);
$this->registered['data'][$key] = $callback;
}
/**
* Ein Bildschirm der Verwaltung, nur für Administratoren. Die Vorlage liegt im Pluginordner.
*
* @param callable(): array $callback liefert die Werte für die Vorlage
*/
public function addAdminPage(string $key, string $title, string $template, callable $callback): void
{
$this->key($key);
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || !is_file($this->directory.'/'.$template)) {
throw new \InvalidArgumentException(sprintf('Die Vorlage „%s“ fehlt im Pluginordner.', $template));
}
$this->registered['adminPages'][$key] = ['key' => $key, 'title' => $title, 'template' => '@plugins/'.$this->name.'/'.$template, 'callback' => $callback];
}
/**
* Ein Knopf auf den Bildschirmen des Plugins. Entweder startet er eine angemeldete Aufgabe
* (`$handler` ist ihr Schlüssel, die Sperre des Aufgabenplans gilt) oder ruft einen Callback,
* dessen Rückgabe als Meldung erscheint.
*
* @param string|callable(): string $handler
*/
public function addAdminAction(string $key, string $label, string|callable $handler): void
{
$this->key($key);
$this->registered['adminActions'][$key] = ['key' => $key, 'label' => $label, 'handler' => $handler];
}
// ---------------------------------------------------------------- Werkzeuge
/**
* Ruft eine HTTPS-Adresse ab. Wirft bei einem Netzfehler; ein HTTP-Fehler ist eine Antwort.
*
* @param array{userAgent?: string, timeout?: int, maxBytes?: int} $options
*
* @return array{status: int, body: string, contentType: string}
*/
public function fetch(string $url, array $options = []): array
{
if (!str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException('Abgerufen werden nur HTTPS-Adressen.');
}
if (!function_exists('curl_init')) {
throw new \RuntimeException('Auf diesem Hosting fehlt die PHP-Erweiterung cURL.');
}
$limit = min(self::MAX_BYTES, $options['maxBytes'] ?? self::MAX_BYTES);
$body = '';
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => max(1, min(60, $options['timeout'] ?? 20)),
CURLOPT_USERAGENT => $options['userAgent'] ?? 'KantSites-Plugin/'.$this->name,
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body, $limit): int {
$body .= $chunk;
return strlen($body) > $limit ? 0 : strlen($chunk);
},
]);
if (false === curl_exec($handle)) {
throw new \RuntimeException('Abruf gescheitert: '.curl_error($handle));
}
return ['status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE), 'body' => $body, 'contentType' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE)];
}
/**
* Legt Bilddaten in der Mediathek ab und gibt den Eintrag zurück.
*
* @throws \InvalidArgumentException wenn die Daten kein brauchbares Bild sind
*/
public function storeImage(string $bytes, string $title, string $alt = ''): array
{
$temp = tempnam(sys_get_temp_dir(), 'kant-plugin-');
if (false === $temp || false === file_put_contents($temp, $bytes)) {
throw new \RuntimeException('Die Bilddaten ließen sich nicht zwischenspeichern.');
}
try {
$item = $this->media->upload(new UploadedFile($temp, 'bild.jpg', null, null, true));
$this->media->update($item['id'], ['title' => mb_substr('' !== trim($title) ? $title : 'Bild', 0, 40), 'alt' => mb_substr($alt, 0, 150), 'archived' => false, 'version' => $item['version']]);
return $this->media->find($item['id']) ?? $item;
} finally {
@unlink($temp);
}
}
private function key(string $key): void
{
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,40}$/D', $key)) {
throw new \InvalidArgumentException(sprintf('Der Schlüssel „%s“ besteht aus Kleinbuchstaben, Ziffern und Bindestrichen.', $key));
}
}
}
+18 -6
View File
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
* *
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit * Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und * Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein * Seitenvorlagen für den Editor. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen. * ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
* *
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein * Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011): * ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
@@ -144,6 +144,12 @@ final class PluginRegistry
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]); $this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
} }
/** Die Fassung, für die die plugin.php zuletzt eingerichtet wurde. */
public function markInstalled(string $name): void
{
$this->write($name, ['version' => $this->all()[$name]['version']]);
}
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */ /** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
public function settingsOf(string $name): array public function settingsOf(string $name): array
{ {
@@ -154,8 +160,10 @@ final class PluginRegistry
private function write(string $name, array $fields): void private function write(string $name, array $fields): void
{ {
$existing = $this->rows()[$name] ?? null; $existing = $this->rows()[$name] ?? null;
// Wahrheitswerte als Zahl: false ginge sonst als leere Zeichenkette an die Datenbank.
$fields = array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $fields);
if (null === $existing) { if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]); $this->db->insert('plugin', ['name' => $name, 'active' => 0, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
} else { } else {
$this->db->update('plugin', $fields, ['name' => $name]); $this->db->update('plugin', $fields, ['name' => $name]);
} }
@@ -423,11 +431,15 @@ final class PluginRegistry
} }
$routes = $this->routes($name, $data['routes'] ?? []); $routes = $this->routes($name, $data['routes'] ?? []);
$presets = $this->presets($name, $data['presets'] ?? []); $presets = $this->presets($name, $data['presets'] ?? []);
if ([] === $blocks && [] === $routes && [] === $presets) { $main = $data['main'] ?? null;
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.'); if (null !== $main && (!is_string($main) || 1 !== preg_match('~^[a-zA-Z0-9_-]+\.php$~D', $main) || !is_file($this->directory().'/'.$name.'/'.$main))) {
throw new \RuntimeException(sprintf('Die Datei „%s“ aus "main" fehlt im Pluginordner.', is_string($main) ? $main : ''));
}
if ([] === $blocks && [] === $routes && [] === $presets && null === $main) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder eine plugin.php.');
} }
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets]; return ['main' => $main, 'name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
} }
private function blockDefinition(string $plugin, string $key, array $block, string $version): array private function blockDefinition(string $plugin, string $key, array $block, string $version): array
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace App\Plugin;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Ops\Tasks\Task;
use Doctrine\DBAL\Connection;
/**
* Lädt die plugin.php der aktiven Plugins und führt aus, was sie angemeldet haben (ADR 127).
*
* Ein Plugin, das beim Laden oder in einem Hook eine Ausnahme wirft, darf die Website nicht
* mitreißen: Es fällt für diese Anfrage aus, der Grund steht auf dem Bildschirm „Plugins“. Einen
* echten PHP-Abbruch fängt auch das nicht ab - dann hilft, den Ordner umzubenennen.
*/
final class PluginRuntime
{
/** @var array<string, PluginApi>|null */
private ?array $apis = null;
/** @var array<string, string> */
private array $errors = [];
public function __construct(private PluginRegistry $plugins, private Connection $db, private MediaLibrary $media)
{
}
/** Nach dem Ein- oder Ausschalten: beim nächsten Zugriff neu laden. */
public function reset(): void
{
$this->apis = null;
$this->errors = [];
}
/** @return array<string, string> Plugin => Grund, aus dem es gerade nicht läuft */
public function errors(): array
{
$this->apis();
return $this->errors;
}
/**
* Schaltet ein und richtet ein. Scheitert das Einrichten, bleibt das Plugin aus.
*
* @throws \InvalidArgumentException
*/
public function activate(string $name): void
{
$this->plugins->activate($name);
$this->reset();
if (isset($this->errors()[$name])) {
$reason = $this->errors()[$name];
$this->plugins->deactivate($name);
$this->reset();
throw new \InvalidArgumentException('Das Plugin lässt sich nicht laden: '.$reason);
}
try {
$this->install($this->apis()[$name] ?? null);
} catch (\Throwable $e) {
$this->plugins->deactivate($name);
$this->reset();
throw new \InvalidArgumentException('Das Plugin ließ sich nicht einrichten: '.$e->getMessage());
}
}
/** @throws \InvalidArgumentException */
public function deactivate(string $name): void
{
foreach ($this->apis()[$name]->registered['deactivate'] ?? [] as $callback) {
try {
$callback();
} catch (\Throwable $e) {
// Ausschalten muss immer gehen, gerade bei einem Plugin, das Fehler macht.
$this->errors[$name] = $e->getMessage();
}
}
$this->plugins->deactivate($name);
$this->reset();
}
/**
* Führt ein Ereignis aus. Der Fehler eines Plugins hält die übrigen nicht auf.
*/
public function doAction(string $hook, mixed ...$arguments): void
{
foreach ($this->hooks('actions', $hook) as [$name, $callback]) {
try {
$callback(...$arguments);
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
}
/**
* Reicht einen Wert durch die Filter. Wirft einer, gilt der Wert von davor.
*/
public function applyFilters(string $hook, mixed $value, mixed ...$arguments): mixed
{
foreach ($this->hooks('filters', $hook) as [$name, $callback]) {
try {
$value = $callback($value, ...$arguments);
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
return $value;
}
/** @return list<Task> */
public function tasks(): array
{
$tasks = [];
foreach ($this->apis() as $name => $api) {
foreach ($api->registered['tasks'] as $task) {
$tasks[] = new PluginTask(self::taskName($name, $task['key']), $task['label'], $task['explanation'], $task['interval'], $task['callback']);
}
}
return $tasks;
}
public static function taskName(string $plugin, string $key): string
{
return 'plugin-'.$plugin.'-'.$key;
}
/**
* Die Seite eines Plugins zu dieser Adresse, in der Form einer gespeicherten Seite - oder null.
*/
public function page(string $path): ?array
{
foreach ($this->apis() as $name => $api) {
foreach ($api->registered['pages'] as $page) {
if (1 !== preg_match($page['regex'], $path, $match)) {
continue;
}
try {
$result = $page['callback'](array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY));
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
return null;
}
return is_array($result) ? $this->pageRecord($name, $path, $result) : null;
}
}
return null;
}
public function data(string $plugin, string $key, array $arguments = []): mixed
{
$callback = $this->apis()[$plugin]->registered['data'][$key] ?? null;
if (null === $callback) {
return null;
}
try {
return $callback($arguments);
} catch (\Throwable $e) {
$this->errors[$plugin] = $e->getMessage();
return null;
}
}
/** @return array{pages: array<string, array>, actions: array<string, array>} */
public function admin(string $plugin): array
{
$api = $this->apis()[$plugin] ?? null;
return ['pages' => $api?->registered['adminPages'] ?? [], 'actions' => $api?->registered['adminActions'] ?? []];
}
/** @return array<string, PluginApi> nur Plugins, deren plugin.php fehlerfrei geladen wurde */
private function apis(): array
{
if (null !== $this->apis) {
return $this->apis;
}
$this->apis = [];
foreach ($this->plugins->all() as $name => $plugin) {
if (!$plugin['active'] || null !== $plugin['error'] || null === ($plugin['main'] ?? null)) {
continue;
}
$directory = $this->plugins->directory().'/'.$name;
$api = new PluginApi($name, $directory, $this->plugins, $this->db, $this->media);
try {
$boot = self::load($directory.'/'.$plugin['main']);
if (!is_callable($boot)) {
throw new \RuntimeException($plugin['main'].' gibt keine Funktion zurück.');
}
$boot($api);
// Eine neue Fassung im Ordner: einrichten, bevor etwas sie benutzt.
if ($plugin['activatedVersion'] !== $plugin['version']) {
$this->install($api);
$this->plugins->markInstalled($name);
}
$this->apis[$name] = $api;
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
return $this->apis;
}
/** In einer eigenen statischen Funktion, damit die Datei nichts von hier sieht. */
private static function load(string $file): mixed
{
return require $file;
}
private function install(?PluginApi $api): void
{
foreach ($api?->registered['install'] ?? [] as $callback) {
$callback();
}
}
/** @return list<array{string, callable}> nach Priorität, bei Gleichstand in Reihenfolge der Plugins */
private function hooks(string $kind, string $hook): array
{
$found = [];
foreach ($this->apis() as $name => $api) {
foreach ($api->registered[$kind][$hook] ?? [] as [$priority, $callback]) {
$found[] = [$priority, $name, $callback];
}
}
usort($found, static fn (array $a, array $b): int => $a[0] <=> $b[0]);
return array_map(static fn (array $entry): array => [$entry[1], $entry[2]], $found);
}
private function pageRecord(string $plugin, string $path, array $result): array
{
return [
'id' => 'plugin-'.$plugin, 'path' => ltrim($path, '/'), 'type' => 'page', 'locale' => 'de',
'seo_title' => (string) ($result['seoTitle'] ?? ''),
'robots' => 'noindex' === ($result['robots'] ?? 'index') ? 'noindex' : 'index',
'status' => (int) ($result['status'] ?? 200),
'og_media_id' => $result['image'] ?? null,
'header_area' => null, 'footer_area' => null,
'document' => [
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
'title' => (string) ($result['title'] ?? ''),
'description' => (string) ($result['description'] ?? ''),
'blocks' => $result['blocks'] ?? [],
],
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Plugin;
use App\Ops\Tasks\Task;
/** Eine Aufgabe, die ein Plugin angemeldet hat, im Vertrag des Aufgabenplans. */
final class PluginTask implements Task
{
/** @param callable(): string $callback */
public function __construct(
private string $name,
private string $label,
private string $explanation,
private int $interval,
private $callback,
) {
}
public function name(): string
{
return $this->name;
}
public function label(): string
{
return $this->label;
}
public function explanation(): string
{
return $this->explanation;
}
public function interval(): int
{
return $this->interval;
}
public function run(): string
{
return (string) ($this->callback)();
}
}
+4 -4
View File
@@ -81,8 +81,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.', 'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.', 'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
'von' => 'by', 'von' => 'by',
'← Neuere' => '← Newer', 'Neuere' => 'Newer',
'Ältere →' => 'Older →', 'Ältere' => 'Older',
'Seite %d von %d' => 'Page %d of %d', 'Seite %d von %d' => 'Page %d of %d',
'Externe Inhalte und Einwilligung' => 'External content and consent', 'Externe Inhalte und Einwilligung' => 'External content and consent',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.', 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
@@ -173,8 +173,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.', 'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.', 'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
'von' => 'yazan:', 'von' => 'yazan:',
'← Neuere' => '← Daha yeni', 'Neuere' => 'Daha yeni',
'Ältere →' => 'Daha eski →', 'Ältere' => 'Daha eski',
'Seite %d von %d' => 'Sayfa %d / %d', 'Seite %d von %d' => 'Sayfa %d / %d',
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay', 'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.', 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
+3 -1
View File
@@ -5,6 +5,7 @@ namespace App\Twig;
use App\Content\MediaLibrary; use App\Content\MediaLibrary;
use App\Domain\InvalidDocument; use App\Domain\InvalidDocument;
use App\Plugin\PluginRegistry; use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction; use Twig\TwigFunction;
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
*/ */
final class PluginExtension extends AbstractExtension final class PluginExtension extends AbstractExtension
{ {
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader) public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader, private PluginRuntime $runtime)
{ {
// Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein // Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein
// fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler // fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler
@@ -33,6 +34,7 @@ final class PluginExtension extends AbstractExtension
new TwigFunction('kant_plugin_props', $this->props(...)), new TwigFunction('kant_plugin_props', $this->props(...)),
new TwigFunction('kant_plugin_media', $this->mediaItem(...)), new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)), new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
new TwigFunction('kant_media', $this->adminMedia(...)), new TwigFunction('kant_media', $this->adminMedia(...)),
]; ];
} }
+1
View File
@@ -23,6 +23,7 @@
{%- endfor %} {%- endfor %}
}}</script> }}</script>
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></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 styles %}{% endblock %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts` {# 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 {# The class is what the security test looks for: an editor must not even be
offered this. #} offered this. #}
<div class="delete-panel"> <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="_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="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}"> <input type="hidden" name="expectedVersion" value="{{ page.version }}">
+1 -1
View File
@@ -123,7 +123,7 @@
{% if mail.protokoll is empty %} {% if mail.protokoll is empty %}
<span class="muted">var/log/mail.log</span> <span class="muted">var/log/mail.log</span>
{% else %} {% 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') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="text-button" type="submit">Protokoll leeren</button> <button class="text-button" type="submit">Protokoll leeren</button>
</form> </form>
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="sheet-part"> <div class="sheet-part">
<h4>Entfernen</h4> <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> <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') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('partials') }}">
<button class="subtle danger-text" type="submit">Sektion entfernen</button> <button class="subtle danger-text" type="submit">Sektion entfernen</button>
</form> </form>
+19
View File
@@ -0,0 +1,19 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'plugins' %}
{% block title %}{{ page.title }}{% endblock %}
{% block heading %}{{ page.title }}{% endblock %}
{% block lead %}{{ plugin.title }} · <a href="{{ path('admin_plugins') }}">Zurück zu den Plugins</a>{% endblock %}
{% block content %}
{% if actions %}
<div class="favicon-actions">
{% for action in actions %}
<form method="post" action="{{ path('admin_plugin_action', {name: plugin.name, key: action.key}) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
<input type="hidden" name="zurueck" value="{{ page.key }}">
<button class="secondary" type="submit">{{ action.label }}</button>
</form>
{% endfor %}
</div>
{% endif %}
{% include page.template with data only %}
{% endblock %}
+3
View File
@@ -56,6 +56,9 @@
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd> <dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% if plugin.main %}<p class="muted">Führt eigenen PHP-Code mit den vollen Rechten der Website aus. Nur einschalten, wenn du der Quelle vertraust.</p>{% endif %}
{% if runtimeErrors[plugin.name] is defined %}<p class="field-error">Fehler im Plugin: {{ runtimeErrors[plugin.name] }}</p>{% endif %}
{% for page in admin[plugin.name].pages %}<p><a href="{{ path('admin_plugin_page', {name: plugin.name, key: page.key}) }}">{{ page.title }}</a></p>{% endfor %}
{% if plugin.active and plugin.settings is not empty %} {% if plugin.active and plugin.settings is not empty %}
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings"> <form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
+29 -2
View File
@@ -4,7 +4,7 @@
{% block heading %}Unternehmen und Adressen{% endblock %} {% block heading %}Unternehmen und Adressen{% endblock %}
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %} {% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %} {% block content %}
{% if missingLegal is not empty %} {% if missingLegal is not empty %}
@@ -104,6 +104,7 @@
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body"> <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="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="expected" value="{{ settingsStamp }}">
<div class="fields"> <div class="fields">
<div> <div>
<label for="legalName">Name des Unternehmens</label> <label for="legalName">Name des Unternehmens</label>
@@ -175,6 +176,32 @@
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p> <p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label> <label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p> <p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label for="businessType">Art des Unternehmens</label>
<select id="businessType" name="businessType">
{% for wert, name in businessTypes %}<option value="{{ wert }}"{{ settings.businessType|default('LocalBusiness') == wert ? ' selected' : '' }}>{{ name }}</option>{% endfor %}
</select>
<p class="field-help">Gilt nur für ein lokales Unternehmen und steht nur in den strukturierten Daten.</p>
<div class="fields">
<div>
<label for="latitude">Breitengrad</label>
<input id="latitude" name="latitude" inputmode="decimal" value="{{ settings.latitude|default('') }}" placeholder="48.79381">
</div>
<div>
<label for="longitude">Längengrad</label>
<input id="longitude" name="longitude" inputmode="decimal" value="{{ settings.longitude|default('') }}" placeholder="8.33237">
</div>
</div>
<p class="field-help">Der Standort als Koordinaten, freiwillig. Beide Werte oder keinen.</p>
<h3 id="logo-title">Logo für Suchmaschinen</h3>
<div class="favicon-field" data-logo-field aria-labelledby="logo-title" role="group">
<input type="hidden" name="organisationLogoMediaId" value="{{ settings.organisationLogoMediaId|default('') }}">
<div class="favicon-current"><span>{{ settings.organisationLogoMediaId|default('') ? 'Logo gewählt.' : 'Kein Logo gewählt.' }}</span></div>
<div class="favicon-actions">
<button type="button" class="secondary" data-logo-choose>{{ settings.organisationLogoMediaId|default('') ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
<button type="button" class="text-button danger-text" data-logo-remove{{ settings.organisationLogoMediaId|default('') ? '' : ' hidden' }}>Logo entfernen</button>
</div>
</div>
<p class="field-help">Ein Bild aus der Mediathek, mindestens 112 Pixel breit und hoch. Es steht nur in den strukturierten Daten; das sichtbare Logo im Kopfbereich bleibt davon unberührt.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label> <label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p> <p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div> <div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
@@ -364,7 +391,7 @@
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td> <td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{{ entry.status }}</td> <td>{{ entry.status }}</td>
<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="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="remove" value="{{ entry.id }}"> <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> <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]} %} {% 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') }}"> <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="_csrf_token" value="{{ csrf_token('theme') }}">
<input type="hidden" name="expected" value="{{ themeStamp }}">
<section class="card"> <section class="card">
<div class="card-head"> <div class="card-head">
+1 -1
View File
@@ -87,7 +87,7 @@
<div class="sheet-part"> <div class="sheet-part">
<h4>Konto löschen</h4> <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> <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') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
<button class="subtle danger-text" type="submit">Konto löschen</button> <button class="subtle danger-text" type="submit">Konto löschen</button>
</form> </form>
+1 -1
View File
@@ -78,7 +78,7 @@
{% elseif type == 'quote' %} {% elseif type == 'quote' %}
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}"> <figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
{% if props.rating|default(0) > 0 %} {% if props.rating|default(0) > 0 %}
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p> <p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}<svg class="{{ i <= props.rating ? 'quote-star-voll' : 'quote-star' }}" viewBox="0 0 24 24" focusable="false"><path d="m12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z"/></svg>{% endfor %}</span></p>
{% endif %} {% endif %}
<blockquote><p>{{ props.text }}</p></blockquote> <blockquote><p>{{ props.text }}</p></blockquote>
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption> <figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
+1
View File
@@ -68,6 +68,7 @@
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z', heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z', star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20', home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3', building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z', wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z', hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
+2 -2
View File
@@ -41,8 +41,8 @@
{% if pages|default(1) > 1 %} {% if pages|default(1) > 1 %}
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #} {# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}"> <nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %} {% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ 'Neuere'|kant_t }}</a>{% endif %}
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span> <span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %} {% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere'|kant_t }}</a>{% endif %}
</nav> </nav>
{% endif %} {% endif %}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more