Compare commits
28
Commits
daaeb52588
...
v1.39.0
No files matched your search
+47
-1
@@ -208,6 +208,52 @@
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8112
|
||||
},
|
||||
{
|
||||
"name": "ebi-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8106'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8106 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8106
|
||||
},
|
||||
{
|
||||
"name": "ebi-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8107'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8107 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8107
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"runtimeExecutable": "C:/php84/php.exe",
|
||||
"runtimeArgs": ["-d", "curl.cainfo=C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt", "-S", "127.0.0.1:8115", "-t", "zentrale/public", "zentrale/public/index.php"],
|
||||
"port": 8115
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,6 @@ MAILER_DSN=sendmail://default
|
||||
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
|
||||
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.
|
||||
#ADMIN_PATH=admin
|
||||
# Die Zentrale, bei der sich diese Installation mit Adresse und Fassung meldet und von der sie
|
||||
# Updates annimmt. Ohne Eintrag: https://sites.kant-it.de. Abschalten mit ZENTRALE_URL=aus.
|
||||
#ZENTRALE_URL=https://sites.kant-it.de
|
||||
@@ -18,3 +18,8 @@
|
||||
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
|
||||
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
|
||||
/public/assets/build/
|
||||
|
||||
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
|
||||
/zentrale/config.php
|
||||
/zentrale/var/*
|
||||
!/zentrale/var/.htaccess
|
||||
@@ -310,6 +310,8 @@ Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-
|
||||
|
||||
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
|
||||
|
||||
Die Aufgabe „Bei der Zentrale melden“ schickt alle 15 Minuten Adresse und Fassung an `https://sites.kant-it.de` und holt dort angestoßene Updates ab (ADR 123, `zentrale/README.md`). Abschalten mit `ZENTRALE_URL=aus` in `.env`; Installationen mit lokalem `SITE_ORIGIN` melden nie. Protokollprüfung: `php scripts/zentrale-check.php`.
|
||||
|
||||
Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
|
||||
@@ -401,6 +403,14 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
|
||||
|
||||
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
|
||||
|
||||
### Redesign-Entwurf: EBI Immobilien (Karlsruhe)
|
||||
|
||||
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/`.
|
||||
|
||||
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`.
|
||||
Generated
+267
-266
File diff suppressed because it is too large.
Load diff
+23
-1
@@ -14,6 +14,9 @@ parameters:
|
||||
kant.mailer_dsn: 'sendmail://default'
|
||||
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
|
||||
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
|
||||
# Die Zentrale, bei der sich jede Installation meldet (ADR 123). ZENTRALE_URL=aus schaltet ab.
|
||||
kant.zentrale_default: 'https://sites.kant-it.de'
|
||||
kant.zentrale_url: '%env(default:kant.zentrale_default:ZENTRALE_URL)%'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
@@ -27,7 +30,7 @@ services:
|
||||
tags: ['kant.task']
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
@@ -45,6 +48,9 @@ services:
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\VideoLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -150,6 +156,22 @@ services:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$plugins: '@App\Plugin\PluginRuntime'
|
||||
App\Ops\Zentrale:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$url: '%kant.zentrale_url%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$senden: null
|
||||
App\Ops\Tasks\ZentraleTask:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$anstossen: null
|
||||
App\EventSubscriber\RemoteUpdateFinishSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Tasks\CleanupTask:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
+53
-1
@@ -1,6 +1,58 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## 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)
|
||||
|
||||
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
|
||||
|
||||
**Entscheidung:** Eigene Tabelle `video` und `VideoLibrary` nach dem Vorbild der Dokumente, Dateien als `<id>.mp4`/`<id>.webm` im Medienordner, bis 64 MB, Typ nach den ersten Bytes (MOV abgewiesen). Ausgeliefert unter `/media/<id>.<endung>` mit Range-Anfragen und `default-src 'none'`. Im Dokument stehen Adressen, nicht Kennungen: Baustein `video` (`src`, `srcWebm`, `poster`, `title`, `mode`: `player`/`loop`) und an der Sektion `props.backgroundVideo`/`backgroundVideoWebm`. Der Validator lässt nur Adressen der eigenen Mediathek zu; fremde Videos bleiben Sache von „Externer Inhalt“ mit Einwilligung.
|
||||
|
||||
Schleifen stehen ohne `autoplay` im HTML, mit `preload="none"`. `video.js` startet sie, solange sie zu sehen sind, und zeigt den Knopf zum Anhalten (WCAG 2.2.2). Bei „Bewegung reduzieren“ oder Datensparen bleibt das Standbild, starten lässt es sich von Hand. Das Skript kommt nur auf Seiten mit Schleife; ein Spieler lässt `script-src 'none'` stehen.
|
||||
|
||||
**Folgen:** Kein Umrechnen auf dem Server: Wer zu große Dateien hochlädt, bekommt zu große Dateien, deshalb der Hinweis im Dialog. Die PHP-Grenzen `upload_max_filesize` und `post_max_size` des Hostings gelten zusätzlich. Die Überlagerung einer Sektion ist Teil ihres CSS-Hintergrunds und liegt damit unter dem Video. Website-Pakete von vor v28 bleiben importierbar, die Tabelle gilt dort als leer.
|
||||
|
||||
## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026)
|
||||
|
||||
**Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht.
|
||||
|
||||
**Entscheidung:** Dieselbe Tabelle `installation` mit neuer Spalte `produkt` (Migration 2 der Zentrale; Altbestand wird `kantsites`), dazu `laufzeit`, `mandanten`, `stoerungen`, `speicher`. Eigener Endpunkt `POST /api/kanttresor/melden` (`Api::meldenTresor`) mit demselben Schlüssel-/HMAC-Verfahren wie ADR 123; eine Kennung bleibt an ihr Produkt gebunden (403 sonst). Die Antwort ist nur `{"ok":true}` – nie ein Auftrag, und `Bestand::beauftragen` lehnt KantTresor-Kennungen ab. Übersicht unter `/kanttresor` mit eigenen Spalten und der Kachel „Dienst gestört oder Speicher knapp“ (≥ 90 %). Als neueste Fassung gilt die höchste gemeldete, weil die KantArchiv-Releaseseite nicht öffentlich ist und ihre Tags nicht gepflegt sind. Gegenstück in KantArchiv: `controlplane/zentrale.py`, beat-Task alle 15 min, ab 1.82.0.
|
||||
|
||||
**Folgen:** Die Zentrale muss neu hochgeladen werden; die Datenbank migriert beim ersten Aufruf. „Veraltet“ ist bei KantTresor relativ zum Bestand – ist keine Instanz auf dem neuesten Stand, fällt das nicht auf. KantTresor gibt keine Tür nach außen frei; er sendet nur. Die IP-Adresse wird wie bei KantSites gespeichert.
|
||||
|
||||
## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
|
||||
|
||||
Anlass: Umut: „Alle KantSites melden sich bei meiner Hauptplattform an … ich kann sehen, welche Domains welche Versionen nutzen, und kann von remote Aktualisierung anstoßen.“ Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle.
|
||||
|
||||
**Meldung (CMS).** Neue Aufgabe `zentrale` (`App\Ops\Tasks\ZentraleTask`, alle 15 Minuten) ruft `App\Ops\Zentrale::melden()` auf: `POST https://sites.kant-it.de/api/melden` mit Kennung, Adresse (`SITE_ORIGIN`), Fassung, PHP-Version, Datenbankart, Git/Archiv, Wartungszustand und Updatestand. Keine Inhalte, keine Besucherdaten, keine Zugangsdaten. Kennung und Schlüssel (32 bzw. 64 Hexzeichen, zufällig) liegen in `var/zentrale.json` und bleiben bei Updates erhalten. Abschalten mit `ZENTRALE_URL=aus`; Installationen mit lokalem `SITE_ORIGIN` (localhost, 127.0.0.1, `*.test`, `*.localhost`, `*.invalid`) melden nie.
|
||||
|
||||
**Echtheit ohne Lizenzschlüssel.** Beim ersten Kontakt schickt die Installation ihren Schlüssel über HTTPS mit (Vertrauen beim ersten Kontakt). Danach ist jede Meldung mit HMAC-SHA256 über den Rohtext unterschrieben (`X-KantSites-Signatur`), jede Antwort der Zentrale ebenso; die Installation verwirft Antworten ohne gültige Unterschrift. Eine bekannte Kennung mit fremdem Schlüssel weist die Zentrale ab (403). Kennt die Zentrale eine Kennung nicht mehr (409), meldet sich die Installation einmal mit Schlüssel neu an. Ein Auftrag kann nur „Update auf eine höhere Fassung der Form 1.2.3“ sein und läuft über `ArchiveUpdate` mit Prüfsumme der Releaseseite – mehr kann auch eine kompromittierte Zentrale nicht auslösen.
|
||||
|
||||
**Pull statt Push.** Die Zentrale verbindet sich nie mit einer Website. Sie merkt sich den Auftrag; die Website holt ihn mit ihrer nächsten Meldung ab. Keine Installation braucht dafür eine von außen erreichbare Steuerschnittstelle, und es geht auch hinter Firewalls.
|
||||
|
||||
**Update aus der Ferne.** `ZentraleTask` führt `ArchiveUpdate::run()` aus (Sicherung, Archiv, Prüfsumme, Dateitausch, Wartung an) und markiert den offenen zweiten Schritt mit `fern` (`ArchiveUpdate::markiereFern()`). Den zweiten Schritt erledigt die nächste Anfrage mit dem neuen Code: `RemoteUpdateFinishSubscriber` (kernel.request, Priorität 48, also vor der Wartungsseite) nimmt die Updatesperre, führt `finish()` mit der Funktionsprüfung aus und gibt die Website frei oder schaltet zurück. Damit das nicht auf den nächsten Besucher wartet, ruft die Aufgabe die eigene Startseite einmal auf (bis 300 s). Das Ergebnis geht mit der nächsten Meldung an die Zentrale und wird nach der Quittung aus `var/zentrale.json` gelöscht. Git-Arbeitskopien lehnen Aufträge ab. Die Funktionsprüfung nach den Migrationen ist dafür aus `SystemController` in `App\Ops\Funktionspruefung` gewandert; die Verwaltung nutzt denselben Dienst.
|
||||
|
||||
**Zentrale.** Eigener Ordner `zentrale/` im Repository, nicht Teil des Releasearchivs (`scripts/build-release.php` nimmt nur die aufgezählten Einträge). Reines PHP ab 8.1 mit SQLite (`var/zentrale.db`), ohne Framework und Composer, damit sie per FTP auf jedes Hosting passt. Anmeldung mit `password_hash`, Sitzungscookie `__Host-` mit SameSite=Strict, CSRF-Token auf jedem Formular, fünf Fehlversuche je IP in 15 Minuten, strenge CSP ohne Skripte, `noindex`. Das erste Konto legt an, wer den `einrichtungscode` aus `config.php` kennt. Die Übersicht zeigt Domain, Kunde, Fassung, letzte Meldung und Auftragsstand, zählt aktuelle, veraltete, verstummte (über 48 h) und nicht zugeordnete Installationen und bietet „Auf x.y.z“ je Zeile und „Alle aktualisieren“ mit Bestätigung (ohne verstummte, wartende und Git-Installationen). Die neueste Fassung kommt von derselben Releaseseite wie bei den Installationen, 30 Minuten zwischengespeichert. Fremde Nutzung erkennt man an „Unbekannt“ (kein Kunde zugeordnet) und am Hinweis „vorher …“ bei einer Kennung, die sich unter neuer Domain meldet.
|
||||
|
||||
**Grenzen.** Wer den Code verändert und die Meldung ausbaut, bleibt unsichtbar; ohne Lizenzschlüssel ist das kein Schutz, nur eine Übersicht. Installationen vor 1.36.0 erscheinen erst nach einem Update von Hand. Erreicht eine Website sich selbst nicht, steht die Wartungsseite bis zum nächsten Besuch oder Cron-Aufruf. Die Zentrale speichert die IP-Adresse der letzten Meldung; das gehört in die Datenschutzerklärung beziehungsweise Auftragsverarbeitung mit den Kunden.
|
||||
|
||||
**Folgen.** Keine Migration im CMS: `var/zentrale.json` entsteht beim ersten Lauf, die Aufgabe trägt sich über ihr Etikett selbst in `scheduled_task` ein. Das Schema der Zentrale migriert sich über `PRAGMA user_version` (`zentrale/app/Datenbank.php`). Protokollprüfung ohne Netz: `php scripts/zentrale-check.php`; der Ablauf mit Dateitausch und Abschluss steht in `tests/ArchiveUpdateTest.php`.
|
||||
|
||||
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
|
||||
|
||||
|
||||
+46
-3
@@ -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**
|
||||
(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
|
||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
||||
Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im
|
||||
Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine
|
||||
`plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127).
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
### Bausteine
|
||||
|
||||
+168
@@ -1,5 +1,173 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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. `VERSION` steht im Arbeitsstand auf 1.39.0, nicht committet, kein Release.
|
||||
|
||||
**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.
|
||||
|
||||
**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)
|
||||
|
||||
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
|
||||
|
||||
**Erledigt (ADR 125, Schema v28, Migration `Version20261104000100`):** `VideoLibrary` und Tabelle `video`; Upload über die Mediathek und den neuen Dialog `openVideos`; Abschnitt „Videos“ in der Mediathek; Auslieferung `/media/<id>.mp4|webm`; Baustein „Video“ (Spieler oder Schleife, Zweitformat WebM, Standbild, Titel); Hintergrundvideo samt WebM an der Sektion; `video.js` startet Schleifen nur sichtbar, mit Knopf zum Anhalten, ohne Start bei „Bewegung reduzieren“ und Datensparen. Website-Paket nimmt Tabelle und Dateien mit, ältere Pakete bleiben importierbar.
|
||||
|
||||
**Geprüft:** PHPUnit 686 Tests unter PHP 8.3, davon nur die sechs `UpdaterTest`-Fälle rot, die in der Testumgebung auch ohne diese Änderung scheitern (kein Git-Verlauf der Arbeitskopie). Neu: `testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground`. 41 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung. Playwright (Chromium) in einer isolierten Installation: Upload beider Formate, Auswahl im Editor, Vorschau, Veröffentlichen, WebM läuft, füllt die Sektion, Knopf hält an, Reduzieren startet nichts und lädt nichts, Range-Antwort 206, keine CSP- und Konsolenfehler.
|
||||
|
||||
**Nicht geprüft:** Safari und echtes iOS-Gerät, H.264 im Testbrowser (Playwright-Chromium spielt nur WebM), Lighthouse mit Video, Upload auf dem Hosting (PHP-Limits).
|
||||
|
||||
**Durchsicht vor dem Release (05.11.2026):** CSP (`default-src 'self'` deckt die Videos), Sicherung, Website-Paket, Auslieferung mit Range und Schema-Migration passen. Nachgebessert: „Video“, „Video anhalten“ und „Video abspielen“ fehlten im Wörterbuch von `kant_t` – auf `/en/`- und türkischen Seiten stand der Knopf deutsch; jetzt Englisch und Türkisch (Französisch fällt wie sonst auf Englisch zurück). `video.js` überspringt ein `data-video-loop` ohne Knopf (etwa aus „Eigenes HTML“), statt mit einem Fehler alle folgenden Videos stehen zu lassen. Im Design-Panel stehen beim Video Größe und Abstand vorn statt der Textgruppe.
|
||||
|
||||
**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.
|
||||
|
||||
**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)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-11.md`, nicht in Git): `symfony/mime` 7.4.19 betroffen von CVE-2026-45070 (E-Mail-Header-Injection über Parameternamen, behoben ab 7.4.22), in `main` und in v1.36.1. KantSites baut keine MIME-Parameternamen aus Eingaben, Ausnutzbarkeit gering. Umut: „Ja, Update + Release“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.x → 7.4.22 (mime, mailer, framework-bundle, http-kernel, security-*, console, yaml, rate-limiter u. a.), Polyfills 1.43.3, masterminds/html5 2.12.0. `npm audit fix` für die Entwicklungswerkzeuge (zod 3.25 → 4.6.7 unter lighthouse/puppeteer-core). `composer.json` unverändert, kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` und `npm audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4; unter 8.2 nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (vorbestehend). php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt).
|
||||
|
||||
**Release:** Tag `v1.36.2`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (3cf4e8c9…), `releases/latest` meldet v1.36.2. Im Archiv steckt `symfony/mime` v7.4.22.
|
||||
|
||||
**Weiter offen aus dem Check:** HSTS auf den Kundenseiten einschalten (System → HSTS); PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.36.2.
|
||||
|
||||
## Zentrale: Reiter KantTresor (27.10.2026)
|
||||
|
||||
**Anlass:** Umut: KantTresor soll sich in der Zentrale melden, eigener Reiter; „was wir nicht brauchen ist Aktualisierung per Knopfdruck“.
|
||||
|
||||
**Erledigt:** `zentrale/`: Migration 2 (`produkt`, `laufzeit`, `mandanten`, `stoerungen`, `speicher`; Altbestand = KantSites), Endpunkt `POST /api/kanttresor/melden`, Reiter KantSites/KantTresor, Übersicht `/kanttresor` mit Kacheln (inkl. „Dienst gestört oder Speicher knapp“), Live-Suche, Detailseite ohne Update- und Auftragsteil, serverseitige Ablehnung von Aufträgen für KantTresor. ADR 124, `zentrale/README.md`. KantArchiv (eigenes Repo): `controlplane/zentrale.py`, Modell `ZentraleMeldung` (Migration 0016), beat-Task `controlplane.zentrale_melden` alle 15 min, Einstellungen `ZENTRALE_URL`/`ZENTRALE_ADRESSE`/`ZENTRALE_POLL_SECONDS`, Tests – dort als 1.82.0 committet und gepusht (fc2019b, b261658).
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` 28/28 (11 neue zu KantTresor). KantArchiv: `tests.test_zentrale` + `test_manage_gui` 24/24, ruff, mypy, `migrate_all` im Dev-Stack. Ende-zu-Ende: echte Meldung aus dem KantTresor-Container per HTTP an die lokale Zentrale (200, Antwortunterschrift gültig). Upgrade einer Zentrale-DB im Stand 1. Browser (Port 8115): Reiter, Filter, Live-Suche, Zurücksetzen bleibt auf `/kanttresor`, Detailseite, Notiz, untergeschobener Update-Auftrag wird abgelehnt, KantSites-Reiter unverändert mit Update-Knöpfen, 375 px ohne Querscrollen, keine Konsolenfehler. Demo-DB wieder gelöscht. Nicht geprüft: Live-Zentrale, Meldung einer echten KantTresor-Produktion über das Internet.
|
||||
|
||||
**Fund unterwegs:** Der Dev-Stack von KantArchiv hat `host.docker.internal` in `DJANGO_ALLOWED_HOSTS` und hätte sich sonst an die echte Zentrale gemeldet. Jetzt gesperrt über `.internal` und `DJANGO_DEBUG=True`; vor dem ersten Takt gestoppt, es ging nichts raus.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (`config.php` und `var/` bleiben). KantTresor-Instanzen mit `./update.sh` auf 1.82.0 heben; nach spätestens 15 min erscheinen sie im Reiter. KantSites-Änderungen sind noch nicht committet.
|
||||
|
||||
## Zentrale: Warnung bei unsicherem PHP (22.10.2026)
|
||||
|
||||
**Anlass:** Kritische PHP-Lücke „php2shell“ (CVE-2026-109618/-109619, CVSS 9.2, Patches vom 21.10.2026: 8.2.35, 8.3.36, 8.4.27, 8.5.12). Umut: Warnung „bei sitesviewer einbauen, nicht in KantSites selbst“.
|
||||
|
||||
**Erledigt:** Nur `zentrale/`, kein Eingriff ins CMS, keine Schemaänderung. `Bestand::PHP_SICHER` (erste sichere Fassung je Zweig) und `PHP_ALT_BIS` (8.1 und älter ohne Updates); `Bestand::phpLuecke()` liefert den Grund oder null. Unbekannte Angaben und neuere Zweige (8.6-Vorabfassungen) gelten nicht als unsicher. Übersicht: neue Kachel/Filter „PHP mit Sicherheitslücke“, rote Pille „PHP x.y.z unsicher“ in der Spalte Zustand, Suche findet auch die PHP-Fassung. Detailseite: Pille plus Hinweis, dass das Update beim Hoster liegt. Bei der nächsten PHP-Sicherheitsmeldung `PHP_SICHER` nachziehen.
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` grün (drei neue Prüfungen zur PHP-Regel). Lokal im Browser (Port 8115, drei Demo-Installationen mit 8.4.22, 8.4.27, 8.1.30): Kachel zählt 2, Filter zeigt genau die zwei, Detailseite mit Hinweis. Demo-Datenbank wieder gelöscht. Nicht geprüft: Mobilansicht, Live-Zentrale.
|
||||
|
||||
**Bekannte Grenze:** Distributionen mit zurückportierten Patches (z. B. Debian/Ubuntu) melden weiter die alte Versionsnummer und erscheinen dann fälschlich als unsicher. Installationen vor 1.36.0 melden kein PHP.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`, neu gebaut); `config.php` und `var/` auf dem Server bleiben. PHP bei allen Hostings auf 8.4.27 bringen.
|
||||
|
||||
## Symfony-Patchupdate, Release 1.36.1 (14.10.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-10.md`, nicht in Git): keine Sicherheitslücken, aber Patch-Rückstand bei Symfony. Umut: „dann mach neueste symfony version dann kann ich den updater mal testen“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.15–7.4.18 → 7.4.19–7.4.21 (framework-bundle, http-kernel, security-*, twig-bundle, console, yaml u. a.), Polyfills 1.43.0, twig/twig 3.28 → 3.30, doctrine/lexer 3.0.3. Bewusst bei der LTS-Linie 7.4 geblieben (Sicherheitssupport bis 11/2029); 8.x wäre ein Major-Sprung mit PHP-8.4-Pflicht. `composer.json` unverändert, Plattform weiter PHP 8.2.12. Kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4 und 8.2; unter 8.2 eine Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (`file_get_contents` auf eine von `git checkout` entfernte `.env`, `Updater.php:468`), die mit der alten `composer.lock` genauso auftritt – vorbestehend. php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün (erster Lauf hing einmal beim Warten auf „Veröffentlicht“, zweiter Lauf vollständig grün), `test:browser` gegen 8080 grün (zwei Kontrastbefunde aus im Editor gesetzten Farben auf `/grundlagen`, Inhalt, nicht Code).
|
||||
|
||||
**Release:** Tag `v1.36.1`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (fe286f49…), `releases/latest` meldet v1.36.1.
|
||||
|
||||
**Nächster Schritt:** Umut testet den Updater: eine Installation auf 1.36.0 per „System“ oder über die Zentrale auf 1.36.1 heben. Weitere Punkte aus dem Sicherheitscheck: HSTS auf den Kundenseiten einschalten (System → HSTS), PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
## Zentrale: Suche, Filter und Seiten (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du Suche und Pagination dort einbauen bei Zentrale“.
|
||||
|
||||
**Erledigt:** Übersicht in `zentrale/` mit Suche (Domain, frühere Domain, Kunde/Notiz, Fassung, IP), Zählerkacheln als Filter (mit Markierung und `aria-current`), Seiten zu je 25 Einträgen mit Zurück/Weiter und Auslassungen, Trefferzeile als Statusmeldung, eigener Leerzustand „Nichts gefunden“. Domains natürlich sortiert, „www.“ zählt nicht. „Alle aktualisieren“ nennt höchstens acht Domains und „und N weitere“. Kein Eingriff ins CMS, keine Schemaänderung.
|
||||
|
||||
**Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün.
|
||||
|
||||
**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet.
|
||||
|
||||
## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch.
|
||||
|
||||
**Erledigt:** CMS: Aufgabe „Bei der Zentrale melden“ (`zentrale`, 15 min) mit `App\Ops\Zentrale` (HMAC-signierte Meldung, Schlüssel beim ersten Kontakt, Antwort wird geprüft), Auftrag „Update“ über `ArchiveUpdate`, Abschluss durch die nächste Anfrage (`RemoteUpdateFinishSubscriber`), Funktionsprüfung als Dienst `Funktionspruefung`. `ZENTRALE_URL` (Standard `https://sites.kant-it.de`, `aus` schaltet ab), lokale Adressen melden nie. Plattform in `zentrale/` (PHP 8.1+, SQLite, ohne Composer): Einrichten mit Code aus `config.php`, Anmeldung mit Sperre nach Fehlversuchen, Übersicht mit Zählern, „Unbekannt“ für Installationen ohne Kunde, Domainwechsel sichtbar, Update je Zeile und „Alle aktualisieren“ mit Bestätigung, Detailseite mit Stand, Kunde/Notiz, Auftragsverlauf, Zurückziehen und Entfernen. Anleitung: `zentrale/README.md`. ADR 123.
|
||||
|
||||
**Geprüft:** PHPUnit `ArchiveUpdateTest` (16 Tests, darunter der ganze Fernablauf mit Dateitausch, Abschluss durch die Folgeanfrage und Ergebnis an die Zentrale; gefälschte Antwort; lokale Installationen melden nicht), `TaskScreenTest`, `SchedulerTest`, `SystemScreenTest` grün. `php scripts/zentrale-check.php`: 14/14 (CMS-Klasse gegen echte API: Anmeldung, Auftrag abholen, nicht doppelt, Ergebnis, 403 bei falscher Unterschrift und fremdem Schlüssel, Neuanmeldung, Domainwechsel). Plattform lokal im Browser (Port 8115, Demodaten): Einrichten, Anmelden, Übersicht mit echter neuester Fassung 1.35.1 von git.kant-it.de, Einzelauftrag, Zurückziehen, „Alle aktualisieren“, Detailseite, 375 px ohne Querscrollen, keine Konsolenfehler, CSP und Cookie-Flags. Nicht geprüft: echter Lauf über das Internet gegen sites.kant-it.de (gibt es noch nicht), Selbstaufruf auf einem echten Hosting, Lighthouse der Plattform (interne Seite, `noindex`). Volle PHPUnit-Kette: 685 Tests grün (ein Fund unterwegs: `zentrale` musste in die Ausschlussliste von `build-release.php`), php-cs-fixer ohne Befund.
|
||||
|
||||
**Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git.
|
||||
|
||||
**Release:** sites.kant-it.de ist online (Umut; `/api/melden` antwortet, `config.php` nicht abrufbar). Fassung 1.36.0 am 14.10.2026 veröffentlicht: Tag `v1.36.0`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (688a0689…), `releases/latest` meldet v1.36.0.
|
||||
|
||||
**Nächster Schritt:** kant-it.de und die übrigen Live-Installationen unter „System“ auf 1.36.0 heben, erste Meldung in der Zentrale prüfen, das erste Fern-Update mit der nächsten Fassung ausprobieren.
|
||||
|
||||
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
|
||||
|
||||
**Anlass:** Umut: „einfacher Rebuild und Redesign von ebi-immobilien.de, soll vollendet aussehen, aber nicht übertrieben clean“.
|
||||
|
||||
**Erledigt:** Neue Installation `var/ebi-immobilien.db` (Port 8106, Messserver 8107), Aufbau nur per `sh scripts/reset-ebi-immobilien.sh` (Bilder: `build-ebi-immobilien-assets.php`, Inhalte: `seed-ebi-immobilien.php`). Logo, Fotos, Kontaktdaten und Formularfelder von der alten WordPress-Seite; Gestaltung neu: Navy und Logo-Blau, Sand als warme Fläche, Lora/Manrope vom eigenen Server. Startseite (Hero mit Umuts KI-Startbild, Leistungskarten, Suchauftrag, Bewertung, Kontaktband), Formularseiten Suchauftrag (2 Schritte), Immobilienbewertung (3 Schritte, bei Grundstück ohne Zimmer/Baujahr/Küche) und Kontakt, Impressum, Datenschutz, 404, 301 von `/contact-us`, `/immobilien`, `/immobilienbewertungen`, `/property-search`. „Jetzt anrufen“ als Menü-Button, auf dem Telefon im Klappmenü. Kein Kerncode geändert.
|
||||
|
||||
**Geprüft:** Im Browser bei 1440 und 375 px (Startseite, Formularseiten, Klappmenü, bedingte Felder bei „Grundstück“, kein waagrechtes Scrollen). `test:browser` gegen 8106 grün (Barrierefreiheit, Tastatur, ohne JS, Budget: HTML 59 KB). Lighthouse (`audit.mjs`, Median aus 3 Läufen) auf 8107 für `/`, `/suchauftrag`, `/immobilienbewertung`, `/kontakt`: überall 100 in Barrierefreiheit, Best Practices, SEO und Agentic Browsing; Leistung 100, nur `/immobilienbewertung` mobil 99. Ein Formular wurde nicht abgeschickt, der Mailversand ist nicht geprüft. PHPUnit lief nicht (kein Kerncode geändert).
|
||||
|
||||
**Bekannte Lücken:** Eine Objektliste gibt es nicht (das Original zeigte „0 Results“). Im Impressum fehlen Inhaber, Rechtsform und Angaben nach § 34c GewO – die alte Seite nennt sie nicht. Das Startbild hat nur 1672 px Breite.
|
||||
|
||||
**Nächster Schritt:** Impressumsangaben von Umut, dann Hoster/Livegang klären (Inhaltspaket `var/ebi-immobilien-paket.zip` liegt bereit).
|
||||
|
||||
## Fassung 1.35.1: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
|
||||
|
||||
**Anlass:** Umut: E-Mail-Seite und „Inhalte übertragen“ sehen uneinheitlich aus, Unterpunkt „E-Mail-Versand“ steht immer offen, „Logs leeren“ fehlt.
|
||||
|
||||
**Erledigt:** Fließtext in Karten hat jetzt durchgehend 13 px (vorher Browser-16 px neben 13 px), Felder bleiben 16 px. Neue gemeinsame Bausteine in `admin.css`: `.card-section` (Abschnitt mit Trennlinie), `.button-row`, `.form-foot`, `.option`/`.option-list` (Auswahl als Karten), `.pill-error`, Dateiauswahl im Knopfstil. E-Mail-Seite: Wege als Karten mit Erklärung, SMTP 2×2, Testmail-Abschnitt, „Speichern“ als Hauptknopf im Kartenfuß (war ungestylt). Protokoll mit Zustandsplakette, Hinweis vorn, Servermeldung zugeklappt (`admin/_mail_entry.html.twig`). „Protokoll leeren“ (`POST /admin/system/e-mail/protokoll`, löscht `mail.log` und `mail.log.1`). Unterpunkt nur im Bereich System sichtbar; in der waagrechten Leiste (761–1000 px) neben „System“.
|
||||
|
||||
**Geprüft:** `SystemScreenTest` erweitert (Leeren nur mit Token, danach leer, Editor 403, Unterpunkt auf Einstellungen nicht sichtbar). PHPUnit vollständig 682 grün, php-cs-fixer sauber, `lint:twig`, `test:admin`, `test:editor` grün. Im Browser (Hüfner lokal) bei 1240, 900 und 375 px: E-Mail-Seite, Systemseite, Einstellungen; kein waagrechtes Scrollen am Handy.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.35.1`, Release-Eintrag mit Archiv (3635 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Offen:** Hüfner auf 1.35.1 aktualisieren (die offenen Punkte von 1.35.0 gelten weiter).
|
||||
|
||||
## Fassung 1.35.0: E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
|
||||
|
||||
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
|
||||
|
||||
@@ -97,6 +97,10 @@ Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nich
|
||||
|
||||
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
|
||||
|
||||
## Der vierte Weg: aus der Zentrale
|
||||
|
||||
Ab 1.36.0 meldet sich jede Installation bei `sites.kant-it.de` und holt dort angestoßene Updates ab. Es läuft derselbe Archivweg wie über den Knopf: Sicherung, Archiv mit Prüfsumme, Dateitausch, Migrationen mit dem neuen Code, Prüfung der Startseite, sonst Zurückschalten. Den zweiten Schritt erledigt die nächste Anfrage an die Website; die Aufgabe ruft dafür die eigene Startseite einmal auf. Git-Arbeitskopien lehnen Aufträge ab. Einzelheiten: ADR 123, `zentrale/README.md`.
|
||||
|
||||
## Ein Release veröffentlichen
|
||||
|
||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261104000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Videos in der Mediathek: fuer den Video-Baustein und den Hintergrund einer Sektion.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle wie bei den Dokumenten: jede Stelle, die ein Bild sucht, erwartet
|
||||
// Varianten mit Breite und Hoehe.
|
||||
$video = $schema->createTable('video');
|
||||
$video->addColumn('id', 'string', ['length' => 32]);
|
||||
$video->addColumn('title', 'string', ['length' => 160]);
|
||||
$video->addColumn('mime', 'string', ['length' => 16]);
|
||||
$video->addColumn('bytes', 'integer');
|
||||
$video->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$video->addColumn('version', 'integer', ['default' => 1]);
|
||||
$video->addColumn('created_at', 'datetime_immutable');
|
||||
$video->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('video');
|
||||
}
|
||||
}
|
||||
Generated
+47
-47
@@ -213,13 +213,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
|
||||
"integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"modern-tar": "^0.8.6",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -242,9 +242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||
"integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -308,9 +308,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -377,9 +377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/yargs": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -608,14 +608,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"version": "157.0.8090-0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
|
||||
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
@@ -812,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1029,9 +1029,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
|
||||
"integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1100,34 +1100,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.10.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
|
||||
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
|
||||
"version": "25.16.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
|
||||
"integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
"@puppeteer/browsers": "3.2.6",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1704951",
|
||||
"typed-query-selector": "^2.12.3",
|
||||
"webdriver-bidi-protocol": "1.0.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
||||
"version": "0.0.1666840",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
|
||||
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
|
||||
"version": "0.0.1704951",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
|
||||
"integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1285,9 +1285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
|
||||
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1306,9 +1306,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
|
||||
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -1412,9 +1412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
||||
+70
-14
@@ -293,7 +293,24 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0; font-size: 13px; line-height: 1.6 }
|
||||
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
|
||||
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
|
||||
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
|
||||
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
|
||||
.card-body h3:first-child { margin-top: 14px }
|
||||
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
|
||||
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
|
||||
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
|
||||
.card-section > legend + * { clear: both }
|
||||
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
|
||||
.card-section .check-label { margin: 14px 0 0 }
|
||||
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
|
||||
.card-section label.field-first { margin-top: 0 }
|
||||
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
|
||||
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
|
||||
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
input[type=file]:hover::file-selector-button { background: #e8ede6 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
@@ -306,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.card-body > p:first-child { margin-top: 14px }
|
||||
.card-body .primary, .card-body .secondary { margin-top: 22px }
|
||||
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
|
||||
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
|
||||
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
|
||||
.form-foot .primary { margin: 0 }
|
||||
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
|
||||
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
|
||||
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
|
||||
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
|
||||
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
|
||||
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
|
||||
.option:hover { border-color: #a9b8af }
|
||||
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
|
||||
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
|
||||
.option > span { display: grid; gap: 2px; min-width: 0 }
|
||||
.option strong { font-size: 13px; font-weight: 600 }
|
||||
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
|
||||
.check-label input, .option input { accent-color: var(--accent) }
|
||||
|
||||
/* A card that opens: used for everything secondary, so a screen starts calm. */
|
||||
details.card > summary {
|
||||
@@ -376,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.pill-live { background: #e7f0d6; color: #2c5340 }
|
||||
.pill-draft { background: #fff2d9; color: #6f4e18 }
|
||||
.pill-off { background: #f1f2f0; color: #5a635e }
|
||||
.pill-error { background: #fff0eb; color: var(--danger) }
|
||||
|
||||
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
|
||||
that clip each other at the edge of the card. */
|
||||
@@ -593,6 +627,10 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
|
||||
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
|
||||
.nav-sub { display: flex; margin: 0 }
|
||||
.nav-sub .nav-item { padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
@@ -718,23 +756,37 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.mail-result { margin-top: 14px }
|
||||
#mail .facts { margin-top: 0 }
|
||||
.mail-result { margin-top: 16px }
|
||||
.mail-result p { margin: 0 0 6px }
|
||||
.mail-result p:last-child { margin-bottom: 0 }
|
||||
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
|
||||
.mail-result-head { margin: 0 0 10px }
|
||||
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
|
||||
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0 }
|
||||
.nav-sub .nav-item { padding-left: 42px; font-size: 13px }
|
||||
.mail-form { margin-top: 18px; max-width: 640px }
|
||||
.mail-form fieldset { border: 0; margin: 0 0 16px; padding: 0; min-width: 0 }
|
||||
.mail-form legend { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding: 0 }
|
||||
.mail-form .check-label { display: flex; gap: 8px; align-items: baseline; margin: 4px 0 }
|
||||
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px 12px }
|
||||
.mail-grid label { margin-top: 0 }
|
||||
.mail-grid input { width: 100%; box-sizing: border-box }
|
||||
.mail-actions { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.mail-actions button { margin: 0 }
|
||||
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
|
||||
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
|
||||
.nav-sub .nav-item:hover { color: var(--shell-ink) }
|
||||
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
|
||||
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
|
||||
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
|
||||
.mail-current-source { flex-basis: 100%; font-size: 12px }
|
||||
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
|
||||
.mail-grid label { margin: 0 0 7px }
|
||||
.mail-grid > div { margin-bottom: 4px }
|
||||
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
|
||||
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
|
||||
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
|
||||
.mail-entry:first-child { border-top: 0; padding-top: 4px }
|
||||
.mail-entry .pill { justify-self: start; margin-top: 1px }
|
||||
.mail-entry-text p { margin: 0 }
|
||||
.mail-entry-line { overflow-wrap: anywhere }
|
||||
.mail-entry-time { font-size: 12px; color: var(--muted) }
|
||||
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
|
||||
.mail-entry-detail { margin-top: 6px }
|
||||
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
|
||||
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
|
||||
@media (max-width: 560px) {
|
||||
.mail-entry { grid-template-columns: minmax(0, 1fr) }
|
||||
}
|
||||
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
|
||||
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
|
||||
|
||||
@@ -791,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-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)}
|
||||
.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[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}
|
||||
@@ -848,6 +901,9 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.nav-list > li:has(.nav-sub) { display: block }
|
||||
.nav-sub { display: grid; margin: 2px 0 4px }
|
||||
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed', 'video'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', video: 'Video', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', video: '▶', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
|
||||
*/
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'video', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
@@ -132,6 +132,8 @@ export function createBlock(type) {
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
// Leer eingefuegt wie „Eigenes HTML“: auf der Website steht nichts, bis ein Video gewaehlt ist.
|
||||
if (type === 'video') block.props = { src: '', mode: 'player', title: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -197,6 +199,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'video') upgradeDocument(document, 28);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
@@ -348,7 +351,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 27;
|
||||
export const SCHEMA_VERSION = 28;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
@@ -692,7 +695,7 @@ export function relevantProperty(block, key, property) {
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'video', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
|
||||
+55
-1
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, openVideos, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
@@ -549,6 +549,13 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'video') {
|
||||
videoChoice(panel, block, 'src', 'Video', 'MP4 mit H.264 spielt jeder Browser ab.');
|
||||
videoChoice(panel, block, 'srcWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
|
||||
posterChoice(panel, block);
|
||||
field('Verhalten', 'mode', block.props.mode || 'player', { choices: { player: 'Spieler mit Steuerung', loop: 'Schleife ohne Ton' }, help: 'Die Schleife startet von selbst, sobald das Video zu sehen ist, und läuft nur stumm. Wer „Bewegung reduzieren“ eingestellt hat, sieht das Standbild.' });
|
||||
field('Titel', 'title', block.props.title || '', { maxLength: 160, help: 'Worum es im Video geht. Screenreader lesen das vor. Bei einer rein dekorativen Schleife leer lassen.' });
|
||||
}
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
@@ -671,6 +678,8 @@ function renderContent(block, panel) {
|
||||
panel.append(images);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
videoChoice(panel, block, 'backgroundVideo', 'Hintergrundvideo', 'Läuft stumm in der Schleife hinter dem Inhalt. Das Hintergrundbild unter Design bleibt das Standbild, bis das Video läuft, und für alle, die keine Bewegung möchten. Eine Überlagerung liegt nicht über dem Video.');
|
||||
videoChoice(panel, block, 'backgroundVideoWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
}
|
||||
@@ -866,6 +875,51 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Videofeld im Eigenschaftenfeld. Im Dokument steht die Adresse der Datei. */
|
||||
function videoChoice(panel, block, key, labelText, helpText, only = '') {
|
||||
const value = block.props?.[key] || '';
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = labelText; wrap.append(label);
|
||||
const set = src => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 28);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props ??= {};
|
||||
if (src) current.props[key] = src; else delete current.props[key];
|
||||
});
|
||||
renderProperties(); changed();
|
||||
};
|
||||
if (value) { const name = document.createElement('code'); name.textContent = value; wrap.append(name); }
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Video wählen' : 'Video wählen oder hochladen';
|
||||
pick.addEventListener('click', () => openVideos({ only, onChoose: item => set(item.src) }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Video entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = helpText; wrap.append(help);
|
||||
panel.append(wrap);
|
||||
}
|
||||
|
||||
/** Das Standbild eines Videos: ein Bild der Mediathek, in einer Breite, die für ein Video reicht. */
|
||||
function posterChoice(panel, block) {
|
||||
const value = block.props?.poster || '';
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = 'Standbild (optional)'; wrap.append(label);
|
||||
const preview = createMediaPreview({ src: value }); wrap.append(preview.element);
|
||||
const set = src => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
if (src) current.props.poster = src; else delete current.props.poster;
|
||||
});
|
||||
renderProperties(); changed();
|
||||
};
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild wählen';
|
||||
pick.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) set((item.variants.find(variant => variant.width >= 1600) ?? item.variants.at(-1)).src); } }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
|
||||
@@ -32,6 +32,11 @@ DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…`
|
||||
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||
|
||||
**Lora** und **Manrope** (Überschriften und Fließtext des Entwurfs „EBI Immobilien“) stehen unter
|
||||
der SIL Open Font License 1.1, Text in `lora-OFL.txt` und `manrope-OFL.txt`. Copyright 2011 The
|
||||
Lora Project Authors (github.com/cyrealtype/Lora-Cyrillic), Reserved Font Name „Lora“; Copyright
|
||||
2018 The Manrope Project Authors (github.com/googlefonts/manrope).
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
@@ -38,3 +38,15 @@
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-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:'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 */
|
||||
@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}
|
||||
/* 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}
|
||||
/* 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}
|
||||
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.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
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.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -58,6 +58,7 @@ export const PREVIEWS = {
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
video: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10 5.4l5 2.6-5 2.6z"/>',
|
||||
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'video/mp4', 'video/webm'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
|
||||
@@ -206,4 +206,69 @@ export function openDocuments({ onChoose } = {}) {
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Video aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Wie bei den PDFs ein eigener, schmaler Dialog. Mit only: 'video/webm' bietet er nur dieses Format
|
||||
* an, für das Zweitformat neben dem MP4.
|
||||
*/
|
||||
export function openVideos({ onChoose, only = '' } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'video-dialog-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">VIDEOS</p><h2 id="video-dialog-title">Video wählen</h2></div><button class="secondary" id="video-close">Schließen</button></div>
|
||||
<p class="panel-hint">MP4 (H.264) oder WebM bis 64 MB. Das Video wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar. Kleine Dateien laden schneller – ein Hintergrund sollte nur wenige MB haben.</p>
|
||||
<div class="media-tools"><div><label for="video-search">Videos suchen</label><input id="video-search" type="search" placeholder="Titel"></div><div><label for="video-upload">Video hochladen</label><input id="video-upload" type="file" accept="video/mp4,video/webm,.mp4,.webm" disabled></div></div>
|
||||
<p id="video-status" role="status" aria-live="polite">Wird geladen …</p><p id="video-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="video-list" class="document-list" role="group" aria-label="Video auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="video-insert" class="primary" disabled>Video übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#video-error').textContent = message; $('#video-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/videos';
|
||||
const fits = item => !only || item.mime === only;
|
||||
function render() {
|
||||
const query = $('#video-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => fits(item) && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#video-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', item.mime === 'video/webm' ? 'WEBM' : 'MP4', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#video-insert').click(); });
|
||||
$('#video-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#video-list').append(element('p', items.some(fits) ? 'Kein Video mit diesem Titel.' : 'Noch keine passenden Videos. Lade oben eines hoch.', 'muted'));
|
||||
$('#video-status').textContent = `${items.filter(fits).length} Videos${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#video-insert').disabled = !chosen;
|
||||
}
|
||||
$('#video-search').addEventListener('input', render);
|
||||
$('#video-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#video-upload').addEventListener('change', async () => {
|
||||
const file = $('#video-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 64 * 1024 * 1024) { error(`${file.name}: größer als 64 MB`); $('#video-upload').value = ''; return; }
|
||||
$('#video-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = fits(data.item) ? data.item : null;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#video-upload').value = ''; render();
|
||||
});
|
||||
$('#video-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('Videos konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#video-upload').disabled = false; render();
|
||||
}).catch(err => { $('#video-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -23,3 +23,5 @@
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
/* Die Vorschau läuft ohne Skript; dann blendet Chrome bei jedem Video die Steuerung ein. */
|
||||
.section-video::-webkit-media-controls{display:none!important}
|
||||
@@ -241,6 +241,16 @@ body.consent-open{padding-bottom:320px}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
|
||||
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
|
||||
.block-video{position:relative;margin:0;max-width:100%}
|
||||
.video-media{display:block;width:100%;height:auto;border-radius:10px}
|
||||
.has-video{position:relative;overflow:hidden}
|
||||
.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}
|
||||
.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.is-paused::before{content:"▶"}
|
||||
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.video-toggle[hidden]{display:none}
|
||||
.block-html{max-width:100%}
|
||||
.block-html img,.block-html video,.block-html table{max-width:100%}
|
||||
.block-html table{border-collapse:collapse}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const FIELDS = {
|
||||
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
||||
video: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
};
|
||||
|
||||
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Schleifenvideos: im Hintergrund einer Sektion oder im Video-Baustein.
|
||||
*
|
||||
* Abgespielt wird erst von hier aus, nicht mit autoplay im HTML. So lädt die Seite zuerst ihren
|
||||
* Inhalt, das Video folgt danach (preload="none"), und es läuft nur, solange es zu sehen ist.
|
||||
* Wer „Bewegung reduzieren“ eingestellt hat oder Daten sparen will, bekommt das Standbild und kann
|
||||
* das Video selbst starten. Der Knopf gehört dazu: Bewegung, die länger als fünf Sekunden läuft,
|
||||
* muss sich anhalten lassen (WCAG 2.2.2). Ohne Skript bleibt das Standbild.
|
||||
*/
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const saving = navigator.connection?.saveData === true;
|
||||
|
||||
for (const video of document.querySelectorAll('[data-video-loop]')) {
|
||||
const toggle = video.parentElement.querySelector(':scope > .video-toggle');
|
||||
// Ein data-video-loop aus „Eigenes HTML“ hat keinen Knopf; ohne ihn bleibt es beim Standbild.
|
||||
if (!toggle) continue;
|
||||
let wanted = !reduced.matches && !saving;
|
||||
let visible = false;
|
||||
const paint = () => {
|
||||
toggle.classList.toggle('is-paused', !wanted);
|
||||
toggle.setAttribute('aria-label', wanted ? toggle.dataset.pause : toggle.dataset.play);
|
||||
};
|
||||
const sync = () => {
|
||||
if (!wanted || !visible) { video.pause(); return; }
|
||||
video.play().catch(error => {
|
||||
// Ein Anhalten mitten im Starten ist kein Fehler. Verweigert der Browser das Abspielen,
|
||||
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit.
|
||||
if (error.name === 'NotAllowedError') { wanted = false; paint(); }
|
||||
});
|
||||
};
|
||||
toggle.hidden = false;
|
||||
toggle.addEventListener('click', () => { wanted = !wanted; paint(); sync(); });
|
||||
reduced.addEventListener('change', () => { if (reduced.matches) { wanted = false; paint(); sync(); } });
|
||||
new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(video);
|
||||
paint();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder von ebi-immobilien.de für die Mediathek des Nachbaus vor.
|
||||
*
|
||||
* Quelle sind die Originaldateien der laufenden Website (wp-content/uploads, am 13.10.2026
|
||||
* abgerufen). Daraus entstehen:
|
||||
* - logo.png: das Logo „EBI Immobilien“ ohne Rand, 680 px breit (Original 1361 × 407),
|
||||
* - favicon.png: das Seitensymbol der Website (192 × 192),
|
||||
* - startbild.webp: „startbild.*“ aus dem Quellordner, sonst das Wohnquartier aus dem Original-Hero,
|
||||
* - bewertung.webp: Ordner, Rechner und Stift (Original „workbook“), auf 1600 px verkleinert,
|
||||
* - schreibtisch.webp: Schreibtisch mit Brille und Notizblock (Original „services-h-fig“).
|
||||
*
|
||||
* Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit den Originalen> <Zielordner>
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/EBI.png')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit EBI.png usw.> <Zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** Ein Ausschnitt, auf eine Zielbreite verkleinert, als WebP. */
|
||||
$webp = static function (string $datei, string $name, int $x, int $y, int $b, int $h, int $breite) use ($quelle, $ziel): void {
|
||||
$pfad = $quelle.'/'.$datei;
|
||||
$bild = str_ends_with($datei, '.png') ? imagecreatefrompng($pfad) : imagecreatefromjpeg($pfad);
|
||||
if (false === $bild) {
|
||||
fwrite(STDERR, "Bild lässt sich nicht lesen: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, $x, $y, $breite, $hoehe, $b, $h);
|
||||
imagewebp($neu, $ziel.'/'.$name, 78);
|
||||
echo "$name: {$breite} × {$hoehe}\n";
|
||||
};
|
||||
|
||||
// Startbild: liegt im Quellordner eine Datei „startbild.jpg/.png/.webp“ (z. B. ein neu erzeugtes
|
||||
// Bild), wird sie auf 1920 px Breite und höchstens 16:9 gebracht. Sonst das Original-Hero, das oben
|
||||
// einen 116 px hohen grauen Balken trägt (Zuschnitt des Themes).
|
||||
$eigenes = glob($quelle.'/startbild.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
|
||||
if ([] !== $eigenes) {
|
||||
$pfad = $eigenes[0];
|
||||
$bild = match (strtolower(pathinfo($pfad, PATHINFO_EXTENSION))) {
|
||||
'png' => imagecreatefrompng($pfad),
|
||||
'webp' => imagecreatefromwebp($pfad),
|
||||
default => imagecreatefromjpeg($pfad),
|
||||
};
|
||||
$b = imagesx($bild);
|
||||
$h = min(imagesy($bild), (int) round($b * 9 / 16));
|
||||
$y = (int) round((imagesy($bild) - $h) / 2);
|
||||
$breite = min(1920, $b);
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, 0, $y, $breite, $hoehe, $b, $h);
|
||||
imagewebp($neu, $ziel.'/startbild.webp', 78);
|
||||
echo "startbild.webp aus ".basename($pfad).": {$breite} × {$hoehe}\n";
|
||||
} else {
|
||||
$webp('karlsruhe-3518473_1920-e1600241476184.png', 'startbild.webp', 0, 118, 1920, 722, 1920);
|
||||
}
|
||||
$webp('workbook-1205044_1920-1920x1280.jpg', 'bewertung.webp', 0, 0, 1920, 1280, 1600);
|
||||
$webp('services-h-fig-1920x1280.jpg', 'schreibtisch.webp', 0, 0, 1920, 1280, 1600);
|
||||
|
||||
// Logo: durchsichtigen Rand abschneiden und verkleinern.
|
||||
$logo = imagecreatefrompng($quelle.'/EBI.png');
|
||||
[$links, $oben, $rechts, $unten] = [imagesx($logo), imagesy($logo), 0, 0];
|
||||
for ($y = 0; $y < imagesy($logo); ++$y) {
|
||||
for ($x = 0; $x < imagesx($logo); ++$x) {
|
||||
if ((imagecolorat($logo, $x, $y) >> 24 & 0x7F) < 120) {
|
||||
$links = min($links, $x);
|
||||
$rechts = max($rechts, $x);
|
||||
$oben = min($oben, $y);
|
||||
$unten = max($unten, $y);
|
||||
}
|
||||
}
|
||||
}
|
||||
$b = $rechts - $links + 1;
|
||||
$h = $unten - $oben + 1;
|
||||
$breite = 680;
|
||||
$hoehe = (int) round($h * $breite / $b);
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagealphablending($neu, false);
|
||||
imagesavealpha($neu, true);
|
||||
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
|
||||
imagecopyresampled($neu, $logo, 0, 0, $links, $oben, $breite, $hoehe, $b, $h);
|
||||
imagepng($neu, $ziel.'/logo.png', 9);
|
||||
echo "logo.png: {$breite} × {$hoehe}\n";
|
||||
|
||||
copy($quelle.'/cropped-favicon-192x192.png', $ziel.'/favicon.png');
|
||||
echo "favicon.png übernommen\n";
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -68,7 +68,7 @@ echo "Fassung {$version}\n";
|
||||
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
|
||||
// dort, wo eine frische Installation ihre Tabellen anlegen will.
|
||||
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example', '.htaccess'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak', 'zentrale'];
|
||||
|
||||
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
|
||||
$count = 0;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Wirft die EBI-Immobilien-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument (optional): Ordner mit den Originalbildern von ebi-immobilien.de
|
||||
# (EBI.png, cropped-favicon-192x192.png, Hero- und Fotodateien aus wp-content/uploads).
|
||||
# Liegt dort eine startbild.jpg/.png/.webp, ersetzt sie das Startbild; Alternativtext per EBI_STARTBILD_ALT.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/ebi-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/ebi-immobilien-media"
|
||||
assets="$root/var/ebi-immobilien-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-ebi-immobilien-assets.php "$1" "$assets"
|
||||
fi
|
||||
if [ ! -f "$assets/startbild.webp" ] || [ ! -f "$assets/logo.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Ordner der Originalbilder als Argument aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/ebi-immobilien.db"
|
||||
rm -rf "$root/var/ebi-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-ebi-immobilien.php "$assets"
|
||||
# Manrope und Lora 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/ebi-immobilien-paket.zip"
|
||||
"$php" bin/console cms:paket:export --ziel="$root/var/ebi-immobilien-paket.zip"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,978 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut den Redesign-Entwurf „EBI Immobilien“ (Karlsruhe) als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist die laufende WordPress-Seite www.ebi-immobilien.de (am 13.10.2026 abgelesen): Start
|
||||
* mit Karlsruher Wohnquartier im Hero, „Was bieten wir Ihnen“ in drei Punkten, Suchauftrag,
|
||||
* Immobilienbewertung, Kontakt, Impressum und Datenschutz. Logo, Bilder, Formularfelder und
|
||||
* Kontaktdaten sind daraus übernommen; Gestaltung und Texte sind neu: Navy und das Logo-Blau,
|
||||
* Sandton als warme Fläche, Lora für Überschriften, Manrope für Text.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - Die Immobilienliste des Originals ist leer („0 Results“) und hängt an einem Theme mit
|
||||
* Kundenkonten. Der Nachbau hat keine Objektliste; die Startseite führt stattdessen zu
|
||||
* Suchauftrag und Bewertung. Alte Adressen leiten weiter.
|
||||
* - Suchauftrag und Bewertung sind CMS-Formulare in Schritten mit Zusammenfassung statt
|
||||
* Contact Form 7. „Kaufen oder mieten“ ist beim Suchauftrag neu; die Bewertungsfragen zu
|
||||
* Zimmern, Baujahr und Küche entfallen bei einem Grundstück.
|
||||
* - Kein Cookie-Banner: die Seite lädt nichts von Dritten.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-ebi-immobilien.sh).
|
||||
* Erstes Argument: der Ordner aus build-ebi-immobilien-assets.php.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'ebi-immobilien')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die EBI-Immobilien-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$redirects = new RedirectRepository($db);
|
||||
$theme = new Theme($db);
|
||||
|
||||
// Angaben aus dem Impressum von ebi-immobilien.de (13.10.2026).
|
||||
$firma = 'EBI Immobilien';
|
||||
$strasse = 'Hopfenstraße 10b';
|
||||
$plz = '76185';
|
||||
$ort = 'Karlsruhe';
|
||||
$telefon = '0172 6110975';
|
||||
$tel = 'tel:+491726110975';
|
||||
$mail = 'info@ebi-immobilien.de';
|
||||
$SCHEMA = DocumentValidator::SCHEMA_VERSION;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = '', string $breite = 'normal', string $sichtbar = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
$layout = [];
|
||||
if ('normal' !== $breite) {
|
||||
$layout['width'] = $breite;
|
||||
}
|
||||
if ('all' !== $sichtbar) {
|
||||
$layout['visibility'] = $sichtbar;
|
||||
}
|
||||
if ([] !== $layout) {
|
||||
$block['layout'] = $layout;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']];
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], string $css = '') use ($row): array {
|
||||
// Ein eigener Rand ergänzt den oberen Nullrand, statt ihn zu ersetzen (sonst greift der
|
||||
// Standardabstand einer Zeile).
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, ...$extra, 'margin' => ['top' => '0px', ...($extra['margin'] ?? [])]]];
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style, $css);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static fn (string $id, string $name, int $groesse, array $style = []): array => ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
|
||||
/**
|
||||
* Ein Button: Pfeil, der beim Überfahren nach rechts gleitet, und ein leichtes Anheben.
|
||||
*
|
||||
* @param string $art blau (Theme), weiss (Weiß auf dunkel), rahmen (Navy-Rahmen auf hell), hell (Rahmen auf dunkel)
|
||||
*/
|
||||
$knopf = static function (string $id, string $inhalt, string $href, string $art = 'blau', array $style = [], string $icon = 'arrow-right'): array {
|
||||
$farbe = [
|
||||
'blau' => '',
|
||||
'weiss' => "background:#fff;color:#14273a;\n&:hover{background:#eaf5fb;color:#14273a}\n",
|
||||
'rahmen' => "background:transparent;color:#14273a;box-shadow:inset 0 0 0 1.5px #14273a;\n&:hover{background:#14273a;color:#fff}\n",
|
||||
'hell' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.6);\n&:hover{background:rgba(255,255,255,.12);color:#fff}\n",
|
||||
][$art];
|
||||
$props = ['text' => $inhalt, 'href' => $href, 'icon' => $icon];
|
||||
if ('phone' === $icon) {
|
||||
$props['iconPosition'] = 'start';
|
||||
} elseif ('none' !== $icon) {
|
||||
$props['iconPosition'] = 'end';
|
||||
$props['iconMotion'] = 'slide';
|
||||
}
|
||||
|
||||
return ['id' => $id, 'type' => 'button', 'props' => $props, 'css' => "min-height:52px;padding:0 26px;margin-top:0;transition:transform .2s ease,background-color .2s ease,color .2s ease;\n".$farbe.'&:hover{transform:translateY(-2px)}']
|
||||
+ ([] === $style ? [] : ['style' => $style]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
$linkCss = "& a{color:#0b6aa2;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#14273a}";
|
||||
|
||||
/** Die Kleinzeile: kurze blaue Linie davor, Versalien. */
|
||||
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@blau', string $unten = '18px') => $text($id, $inhalt, ['desktop' => [
|
||||
'fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'lineHeight' => 1.6, 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten],
|
||||
]], "display:flex;align-items:center;gap:12px\n&::before{content:'';width:28px;height:2px;background:#2ea8e0;flex-shrink:0}");
|
||||
|
||||
$h2Stil = [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '44px', 'fontWeight' => '500', 'lineHeight' => 1.15, 'letterSpacing' => '-0.01em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
|
||||
'tablet' => ['fontSize' => '38px'],
|
||||
'mobile' => ['fontSize' => '31px'],
|
||||
];
|
||||
$h2Hell = [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'color' => '@papier']];
|
||||
$fliess = static fn (string $farbe = '@text', string $unten = '0px', string $breite = 'none', string $groesse = '17px') => [
|
||||
'desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.75, 'color' => $farbe, 'maxWidth' => $breite, 'margin' => ['top' => '0px', 'bottom' => $unten]],
|
||||
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
|
||||
];
|
||||
|
||||
// Ein Link über die ganze Karte: Die Karte ist positioniert, der Link spannt sein ::after auf.
|
||||
$ganzeKarte = "position:relative;\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #2ea8e0;outline-offset:4px}";
|
||||
|
||||
/** Ein Haken vor einer Zeile – für Aufzählungen in Abschnitten. */
|
||||
$haken = static fn (string $id, string $inhalt, string $farbe = '@text', string $hakenfarbe = '@blau') => $zeile($id, [
|
||||
$symbol($id.'-icon', 'check-circle', 24, ['desktop' => ['color' => $hakenfarbe, 'flexShrink' => 0]]),
|
||||
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => $farbe, 'margin' => ['bottom' => '0px']]], "& strong{font-weight:700}
|
||||
".$linkCss),
|
||||
], '12px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '14px']]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => $firma, 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'tinte', 'name' => 'Navy (Überschriften, dunkle Flächen)', 'value' => '#14273a'],
|
||||
['key' => 'navy-tief', 'name' => 'Navy tief (Fußleiste)', 'value' => '#0e1c2a'],
|
||||
['key' => 'blau', 'name' => 'Blau (Knöpfe, kleine Schrift)', 'value' => '#0b6aa2'],
|
||||
['key' => 'blau-dunkel', 'name' => 'Blau dunkel (Knöpfe beim Überfahren)', 'value' => '#08527d'],
|
||||
['key' => 'logo-blau', 'name' => 'Logo-Blau (Linien, Akzente auf Navy)', 'value' => '#2ea8e0'],
|
||||
['key' => 'himmel', 'name' => 'Hellblau (Schrift auf Navy)', 'value' => '#7fd0f2'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#4d5966'],
|
||||
['key' => 'sand', 'name' => 'Sand (warme Flächen)', 'value' => '#f5f1ea'],
|
||||
['key' => 'linie', 'name' => 'Linien', 'value' => '#e3ddd2'],
|
||||
['key' => 'auf-dunkel', 'name' => 'Schrift auf Navy', 'value' => '#c3cfdb'],
|
||||
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
['key' => 'body', 'name' => 'Text', 'family' => 'Manrope', 'source' => 'google', 'weights' => [400, 500, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lora', 'source' => 'google', 'weights' => [500, 600], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '17px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@blau', 'containerWidth' => '1200px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '6px'],
|
||||
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'body', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.png', 'EBI Immobilien'],
|
||||
'favicon' => ['favicon.png', 'Seitensymbol von EBI Immobilien'],
|
||||
'karlsruhe' => ['startbild.webp', getenv('EBI_STARTBILD_ALT') ?: 'Modernes Wohnquartier in der Abenddämmerung mit beleuchteten Balkonen und Rasenfläche'],
|
||||
'bewertung' => ['bewertung.webp', 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch'],
|
||||
'schreibtisch' => ['schreibtisch.webp', 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Formulare – Felder wie auf ebi-immobilien.de, in Schritte geteilt
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$einwilligung = ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass EBI Immobilien meine Angaben zur Bearbeitung meiner Anfrage speichert und mich kontaktiert.'];
|
||||
|
||||
$forms->save('suchauftrag', 'Suchauftrag', [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Wunschimmobilie', 'help' => 'Was suchen Sie? Bis auf die Art der Immobilie ist alles freiwillig.'],
|
||||
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Kaufen oder mieten?', 'required' => true, 'options' => ['Kaufen', 'Mieten']],
|
||||
['name' => 'art', 'type' => 'radio', 'label' => 'Art der Immobilie', 'required' => true, 'options' => ['Wohnung', 'Einfamilienhaus', 'Mehrfamilienhaus']],
|
||||
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => false, 'min' => 1, 'max' => 50],
|
||||
['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche ab', 'required' => false, 'min' => 1, 'max' => 10000, 'unit' => 'm²'],
|
||||
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr ab', 'required' => false, 'min' => 1800, 'max' => 2100],
|
||||
['name' => 'preisspanne', 'type' => 'text', 'label' => 'Preisspanne', 'required' => false, 'help' => 'z. B. bis 450.000 € oder 900 bis 1.200 € warm'],
|
||||
['name' => 'sonstiges', 'type' => 'textarea', 'label' => 'Sonstiges', 'required' => false, 'help' => 'Wunschlage, Balkon, Garten, Stellplatz …'],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir Ihnen passende Angebote schicken können.'],
|
||||
['name' => 'anrede', 'type' => 'radio', 'label' => 'Anrede', 'required' => true, 'options' => ['Herr', 'Frau']],
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Vor- und Nachname', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'strasse', 'type' => 'text', 'label' => 'Straße und Hausnummer', 'required' => false],
|
||||
['name' => 'ort', 'type' => 'text', 'label' => 'PLZ und Ort', 'required' => false],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neuer Suchauftrag über die Website', 'success' => 'Vielen Dank! Ihr Suchauftrag ist bei uns angekommen. Wir melden uns persönlich bei Ihnen, sobald wir ein passendes Objekt haben.', 'replyToField' => 'email', 'buttonLabel' => 'Suchauftrag senden', 'summary' => true], 0);
|
||||
echo "Formular angelegt: suchauftrag\n";
|
||||
|
||||
$gebaeude = ['Wohnung', 'Haus', 'Mehrfamilienhaus'];
|
||||
$forms->save('bewertung', 'Immobilienbewertung', [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Worum geht es?'],
|
||||
['name' => 'objekt', 'type' => 'radio', 'label' => 'Was möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Mehrfamilienhaus', 'Grundstück']],
|
||||
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Verkaufen', 'Vermieten']],
|
||||
|
||||
['name' => 'schritt_details', 'type' => 'step', 'label' => 'Eckdaten', 'help' => 'Ungefähre Angaben genügen für eine erste Einschätzung.'],
|
||||
['name' => 'flaeche', 'type' => 'number', 'label' => 'Fläche', 'required' => true, 'min' => 1, 'max' => 100000, 'unit' => 'm²',
|
||||
'help' => 'Wohnfläche – bei einem Grundstück die Grundstücksfläche'],
|
||||
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr', 'required' => true, 'min' => 1700, 'max' => 2100, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => true, 'min' => 1, 'max' => 200, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'kueche', 'type' => 'radio', 'label' => 'Ist eine Einbauküche vorhanden?', 'required' => true, 'options' => ['Ja – gebraucht', 'Ja – neuwertig', 'Keine vorhanden'], 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
|
||||
['name' => 'parken', 'type' => 'select', 'label' => 'Welche Parkmöglichkeiten gibt es?', 'required' => true, 'options' => ['Tiefgarage', 'Garage', 'Doppelgarage', 'Carport', 'Stellplatz', 'Mehrere', 'Keine']],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Wir melden uns mit einer Einschätzung persönlich bei Ihnen.'],
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Freiwillig – wenn wir Sie lieber anrufen sollen'],
|
||||
['name' => 'lage', 'type' => 'text', 'label' => 'Lage der Immobilie', 'required' => false, 'help' => 'Ort oder Stadtteil, gern mit Straße'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Bewertungsanfrage über die Website', 'success' => 'Vielen Dank! Ihre Bewertungsanfrage ist bei uns angekommen. Wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Bewertung anfragen', 'summary' => true], 0);
|
||||
echo "Formular angelegt: bewertung\n";
|
||||
|
||||
$forms->save('kontakt', 'Kontakt', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false],
|
||||
['name' => 'betreff', 'type' => 'text', 'label' => 'Betreff', 'required' => false],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true],
|
||||
$einwilligung,
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über die Website', 'success' => 'Vielen Dank für Ihre Nachricht. Wir melden uns so bald wie möglich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: Navy-Leiste mit Kontakt (nur Desktop), darunter weiß mit Logo, Menü, Menü-Button
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$leistenLink = "& a{color:#dbe5ee;text-decoration:none;transition:color .2s}\n& a:hover{color:#fff}";
|
||||
$leistenPunkt = static fn (string $id, string $icon, string $inhalt) => $zeile($id, [
|
||||
$symbol($id.'-icon', $icon, 16, ['desktop' => ['color' => '@himmel']]),
|
||||
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]], $leistenLink),
|
||||
], '8px');
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf-leiste', [
|
||||
$zeile('kopf-leiste-zeile', [
|
||||
$text('kopf-leiste-spruch', 'Ihr Immobilienmakler in Karlsruhe und Umgebung', ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('kopf-leiste-kontakt', [
|
||||
$leistenPunkt('kopf-leiste-telefon', 'phone', '<a href="'.$tel.'">'.$telefon.'</a>'),
|
||||
$leistenPunkt('kopf-leiste-mail', 'mail', '<a href="mailto:'.$mail.'">'.$mail.'</a>'),
|
||||
], '28px'),
|
||||
], '20px', ['justifyContent' => 'space-between', 'minHeight' => '40px']),
|
||||
], ['desktop' => ['backgroundColor' => '@tinte', 'padding' => ['top' => '0px', 'bottom' => '0px']]], null, '', 'wide', 'hide-mobile'),
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$bild('kopf-logo', $medien['logo'], $firma.' – zur Startseite', [
|
||||
// Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS).
|
||||
'desktop' => ['width' => '200px', 'maxWidth' => '200px', 'flexShrink' => 0, 'margin' => $seiten('0px')],
|
||||
'mobile' => ['width' => '150px', 'maxWidth' => '150px'],
|
||||
], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '15px', 'fontWeight' => '600', 'color' => '@tinte',
|
||||
'burgerColor' => '@tinte', 'burgerBackgroundColor' => '@papier', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '6px',
|
||||
]],
|
||||
// Blaue Linie, die beim Überfahren von der Mitte aus wächst; das Klappmenü liegt
|
||||
// als Fläche unter dem ganzen Kopf.
|
||||
'css' => "& .menu-list{gap:4px 34px;align-items:center}\n"
|
||||
."& .menu-list a{position:relative;padding:8px 0;text-decoration:none}\n"
|
||||
."& .menu-list a::after{content:'';position:absolute;left:50%;right:50%;bottom:0;height:2px;background:#2ea8e0;transition:left .25s ease,right .25s ease}\n"
|
||||
."& .menu-list a:hover::after{left:0;right:0}\n"
|
||||
."& .menu-list .menu-button{padding:12px 22px}\n"
|
||||
."& .menu-list .menu-button::after{display:none}\n"
|
||||
."& .menu-burger{margin-left:auto}\n"
|
||||
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
|
||||
."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #e3ddd2;box-shadow:0 18px 30px rgba(20,39,58,.12);font-size:18px}\n"
|
||||
."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n"
|
||||
."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #eee8de}\n"
|
||||
.'& .menu-burger-body .menu-list a::after{display:none}'],
|
||||
], '28px', ['justifyContent' => 'space-between', 'minHeight' => '88px'], ['minHeight' => '70px', 'gap' => '6px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '#fffffff7', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'shadowY' => '1px', 'shadowBlur' => '0px', 'shadowColor' => '#14273a1a'],
|
||||
], null, '', 'wide'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich: Sand mit blauer Oberkante, Navy-Leiste unten
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fussLink = "& a{color:#4d5966;text-decoration:none;transition:color .2s}\n& a:hover{color:#0b6aa2}";
|
||||
$fussTitel = static fn (string $id, string $inhalt) => $heading($id, $inhalt, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '19px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '14px']]]);
|
||||
$fussText = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.95, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $fussLink."\n& a{overflow-wrap:anywhere}");
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$column('fuss-raster', [
|
||||
$column('fuss-marke', [
|
||||
$bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '190px', 'maxWidth' => '190px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
$text('fuss-spruch', 'Wir helfen Ihnen, Ihr neues Zuhause zu finden – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'maxWidth' => '300px', 'margin' => ['top' => '20px', 'bottom' => '0px']], 'mobile' => ['maxWidth' => 'none']]),
|
||||
]),
|
||||
$column('fuss-service', [
|
||||
$fussTitel('fuss-service-titel', 'Service'),
|
||||
$fussText('fuss-service-liste', '<a href="/suchauftrag">Suchauftrag erstellen</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/#leistungen">Unsere Leistungen</a>'),
|
||||
]),
|
||||
$column('fuss-kontakt', [
|
||||
$fussTitel('fuss-kontakt-titel', 'Kontakt'),
|
||||
$fussText('fuss-kontakt-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'<br><a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a>'),
|
||||
]),
|
||||
$column('fuss-anfrage', [
|
||||
$fussTitel('fuss-anfrage-titel', 'Fragen?'),
|
||||
$text('fuss-anfrage-text', 'Schreiben Sie uns – wir melden uns persönlich.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '18px']]]),
|
||||
$knopf('fuss-anfrage-knopf', 'Nachricht schreiben', '/kontakt', 'rahmen'),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px', 'padding' => ['top' => '64px', 'bottom' => '52px']],
|
||||
'tablet' => ['gridColumns' => 2],
|
||||
'mobile' => ['gridColumns' => 1, 'gap' => '32px', 'padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
|
||||
]),
|
||||
$section('fuss-leiste', [
|
||||
$zeile('fuss-unten', [
|
||||
$text('fuss-copyright', '© 2026 '.$firma.', Karlsruhe', ['desktop' => ['fontSize' => '13px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('fuss-links', [
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '13px', 'gap' => '24px', 'color' => '@auf-dunkel']],
|
||||
'css' => "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Webdesign by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"),
|
||||
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'padding' => ['top' => '20px', 'bottom' => '22px']],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start']),
|
||||
], ['desktop' => ['backgroundColor' => '@navy-tief', 'padding' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Eine Leistung als Karte: Symbol im hellblauen Kreis, Titel, Satz, Textlink. */
|
||||
$leistung = static function (int $n, string $icon, string $titel, string $satz, string $link, string $href) use ($column, $zeile, $text, $heading, $symbol, $rund, $nurUnten, $ganzeKarte): array {
|
||||
$id = 'leistung-'.$n;
|
||||
|
||||
return $column($id, [
|
||||
$zeile($id.'-kopf', [
|
||||
$column($id.'-symbol', [$symbol($id.'-icon', $icon, 24, ['desktop' => ['color' => '@blau']])], ['desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '60px', 'height' => '60px', 'flexShrink' => 0,
|
||||
'backgroundColor' => '#e8f4fb', 'borderRadius' => $rund('50%'),
|
||||
]], 'transition:background-color .25s ease,color .25s ease'),
|
||||
], '12px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '26px']]),
|
||||
$heading($id.'-titel', $titel, 3, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '24px', 'fontWeight' => '600', 'lineHeight' => 1.25, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '12px']]]),
|
||||
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '24px']]]),
|
||||
$text($id.'-link', '<a href="'.$href.'">'.$link.'</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['top' => 'auto', 'bottom' => '0px']]],
|
||||
"& a{color:#0b6aa2;text-decoration:none;display:inline-flex;align-items:center;gap:8px}\n& a::before{content:'';order:2;width:18px;height:2px;background:currentColor;transition:width .25s ease}\n& a:hover{color:#14273a}"),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'backgroundColor' => '@papier', 'borderRadius' => $rund('8px'),
|
||||
'padding' => ['top' => '34px', 'right' => '32px', 'bottom' => '34px', 'left' => '32px'],
|
||||
'borderWidth' => $nurUnten('3px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff',
|
||||
'shadowY' => '10px', 'shadowBlur' => '30px', 'shadowColor' => '#14273a14'],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'right' => '24px', 'bottom' => '28px', 'left' => '24px']],
|
||||
], $ganzeKarte."\ntransition:transform .3s ease,box-shadow .3s ease,border-color .3s ease;\n&:hover{transform:translateY(-6px);border-bottom-color:#2ea8e0;box-shadow:0 22px 44px rgba(20,39,58,.14)}\n&:hover [id=\"{$id}-symbol\"]{background-color:#0b6aa2;color:#fff}\n&:hover [id=\"{$id}-icon\"]{color:#fff}\n&:hover a::before{width:30px}");
|
||||
};
|
||||
|
||||
/** Ein Schritt mit runder Ziffer. */
|
||||
$schritt = static fn (int $n, string $titel, string $satz) => $zeile('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', (string) $n, ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '40px', 'height' => '40px', 'flexShrink' => 0,
|
||||
'borderRadius' => $rund('50%'), 'borderWidth' => $seiten('1.5px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau',
|
||||
'fontFamily' => 'heading', 'fontSize' => '18px', 'fontWeight' => '600', 'color' => '@blau', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('schritt-'.$n.'-text', '<strong>'.$titel.'</strong>'.$satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]],
|
||||
'& strong{display:block;font-size:17px;font-weight:700;color:#14273a;margin-bottom:2px}'),
|
||||
], '18px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '22px']]);
|
||||
|
||||
/** Eine Kennzahl im Hero: großer Wert, kleine Beschriftung, Linie davor. */
|
||||
$fakt = static fn (string $id, string $wert, string $beschriftung) => $column($id, [
|
||||
$text($id.'-wert', $wert, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '30px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['bottom' => '6px']], 'mobile' => ['fontSize' => '24px']]),
|
||||
$text($id.'-text', $beschriftung, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
|
||||
], ['desktop' => ['borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '2px'], 'borderStyle' => 'solid', 'borderColor' => '@logo-blau', 'padding' => ['left' => '18px']]]);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Finden Sie Ihr zukünftiges Zuhause',
|
||||
'description' => 'EBI Immobilien in Karlsruhe: Wir helfen Ihnen beim Kauf oder der Miete Ihrer Wunschimmobilie und bewerten Ihre Immobilie für Verkauf oder Vermietung.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Karlsruher Wohnquartier, abgedunkelt, Text links, Kennzahlen unten.
|
||||
$section('hero', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-klein', 'Immobilien in Karlsruhe und Umgebung', '@himmel', '22px'),
|
||||
$heading('hero-titel', 'Finden Sie Ihr<br>zukünftiges Zuhause.', 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.08, 'letterSpacing' => '-0.015em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '24px']],
|
||||
'tablet' => ['fontSize' => '52px'],
|
||||
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '18px']],
|
||||
], 'text-shadow:0 2px 24px rgba(10,20,32,.35)'),
|
||||
$text('hero-satz', 'Ob Kauf oder Miete, Wohnung oder Haus: Wir begleiten Sie persönlich auf dem Weg zu Ihrer Wunschimmobilie – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', [
|
||||
'desktop' => ['fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '#eef3f7', 'maxWidth' => '600px', 'margin' => ['bottom' => '36px']],
|
||||
'mobile' => ['fontSize' => '17px', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$zeile('hero-knoepfe', [
|
||||
$knopf('hero-knopf-suche', 'Suchauftrag erstellen', '/suchauftrag'),
|
||||
$knopf('hero-knopf-bewertung', 'Immobilie bewerten lassen', '/immobilienbewertung', 'hell', [], 'none'),
|
||||
], '14px', ['flexWrap' => 'wrap']),
|
||||
$row('hero-fakten', [
|
||||
$fakt('hero-fakt-erfahrung', '15+ Jahre', 'Erfahrung bei Kauf und Miete'),
|
||||
$fakt('hero-fakt-region', 'Karlsruhe', 'und Umgebung'),
|
||||
$fakt('hero-fakt-persoenlich', 'Persönlich', 'ein fester Ansprechpartner'),
|
||||
], ['desktop' => ['columns' => 'three', 'gap' => 'medium'], 'mobile' => ['columns' => 'one', 'gap' => 'small']], [
|
||||
'desktop' => ['margin' => ['top' => '64px'], 'maxWidth' => '760px'],
|
||||
'mobile' => ['margin' => ['top' => '44px']],
|
||||
]),
|
||||
], ['desktop' => ['maxWidth' => '820px']]),
|
||||
], [
|
||||
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.15,
|
||||
'color' => '@papier', 'minHeight' => '82vh', 'display' => 'flex', 'alignItems' => 'center', 'padding' => ['top' => '110px', 'bottom' => '96px']],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
], null, "position:relative\n&::before{content:'';position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,rgba(13,28,44,.78) 0%,rgba(13,28,44,.5) 50%,rgba(13,28,44,.1) 85%);pointer-events:none}\n& .section-inner{position:relative;z-index:1}"),
|
||||
|
||||
// 2 – Leistungen auf Sand: Kopf, drei Karten aus „Was bieten wir Ihnen“.
|
||||
$section('leistungen-bereich', [
|
||||
$row('leistungen-kopf', [
|
||||
$column('leistungen-kopf-titel', [
|
||||
$kleinzeile('leistungen-klein', 'Was wir Ihnen bieten'),
|
||||
$heading('leistungen-titel', 'Kaufen, mieten, verkaufen –<br>mit einem Ansprechpartner.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'margin' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
$text('leistungen-satz', 'Seit über 15 Jahren unterstützen wir Käufer, Mieter und Eigentümer in Karlsruhe. Unkompliziert, ehrlich und mit Blick fürs Detail.', [
|
||||
'desktop' => ['maxWidth' => '380px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
|
||||
]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '20px', 'columnGap' => '48px', 'margin' => ['top' => '0px', 'bottom' => '48px']],
|
||||
'mobile' => ['margin' => ['bottom' => '32px']],
|
||||
]),
|
||||
$column('leistungen-raster', [
|
||||
$leistung(1, 'home', 'Ihr Traumhaus finden', 'Wir helfen Ihnen, das passende Zuhause zu finden – ob zum Kauf oder zur Miete. Sagen Sie uns, was Sie suchen.', 'Suchauftrag erstellen', '/suchauftrag'),
|
||||
$leistung(2, 'award', 'Langjährige Erfahrung', 'Seit über 15 Jahren begleiten wir Immobilienkäufe und Vermietungen. Von der Besichtigung bis zur Schlüsselübergabe.', 'Kontakt aufnehmen', '/kontakt'),
|
||||
$leistung(3, 'document', 'Immobilienbewertung', 'Sie möchten verkaufen oder vermieten? Wir schätzen den Wert Ihrer Immobilie realistisch ein.', 'Zur Bewertung', '/immobilienbewertung'),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '28px'],
|
||||
'tablet' => ['gridColumns' => 1, 'gap' => '20px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '100px', 'bottom' => '104px']],
|
||||
'mobile' => ['padding' => ['top' => '60px', 'bottom' => '60px']],
|
||||
], 'leistungen'),
|
||||
|
||||
// 3 – Suchauftrag: Foto mit versetztem Rahmen links, Text und drei Schritte rechts.
|
||||
$section('suche-bereich', [
|
||||
$row('suche-zeile', [
|
||||
$column('suche-bild-spalte', [
|
||||
$bild('suche-bild', $medien['schreibtisch'], 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock', [
|
||||
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '520px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
|
||||
'mobile' => ['imageHeight' => '300px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
$column('suche-etikett', [
|
||||
$symbol('suche-etikett-icon', 'search', 24, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text('suche-etikett-text', '<strong>Nicht fündig geworden?</strong>Wir suchen für Sie weiter.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]],
|
||||
'& strong{display:block;font-family:Lora,serif;font-size:18px;font-weight:600;color:#fff;margin-bottom:2px}'),
|
||||
], [
|
||||
'desktop' => ['position' => 'absolute', 'right' => '-28px', 'bottom' => '36px', 'display' => 'flex', 'gap' => '14px', 'alignItems' => 'flex-start', 'maxWidth' => '290px',
|
||||
'backgroundColor' => '@tinte', 'borderRadius' => $rund('8px'), 'padding' => ['top' => '20px', 'right' => '22px', 'bottom' => '20px', 'left' => '22px'],
|
||||
'shadowY' => '18px', 'shadowBlur' => '40px', 'shadowColor' => '#14273a40'],
|
||||
'tablet' => ['right' => '20px'],
|
||||
'mobile' => ['right' => '12px', 'left' => '12px', 'bottom' => '12px', 'maxWidth' => 'none'],
|
||||
]),
|
||||
], ['desktop' => ['position' => 'relative']], "&::before{content:'';position:absolute;top:-18px;left:-18px;width:55%;height:60%;border-top:3px solid #2ea8e0;border-left:3px solid #2ea8e0;border-top-left-radius:12px;pointer-events:none}"),
|
||||
$column('suche-text', [
|
||||
$kleinzeile('suche-klein', 'Immobilien-Suchauftrag'),
|
||||
$heading('suche-titel', 'Ihre Traumimmobilie war noch nicht dabei?', 2, $h2Stil),
|
||||
$text('suche-satz', 'Wir unterstützen Sie gern bei Ihrer Suche. Erzählen Sie uns, was Sie sich wünschen – wir halten die Augen offen und melden uns, sobald etwas Passendes dabei ist.', $fliess('@text', '32px', '520px')),
|
||||
$schritt(1, 'Wünsche angeben', 'Art, Größe, Lage und Budget – in zwei kurzen Schritten.'),
|
||||
$schritt(2, 'Wir suchen für Sie', 'Wir gleichen Ihre Wünsche mit unseren Objekten und dem Markt ab.'),
|
||||
$schritt(3, 'Persönliche Rückmeldung', 'Passt etwas, melden wir uns direkt bei Ihnen.'),
|
||||
$knopf('suche-knopf', 'Suchauftrag erstellen', '/suchauftrag', 'blau', ['desktop' => ['margin' => ['top' => '14px']]]),
|
||||
], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start']]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '96px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '112px', 'bottom' => '112px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
], 'suchauftrag'),
|
||||
|
||||
// 4 – Bewertung auf Navy: Text und Haken links, Foto rechts mit blauem Versatz.
|
||||
$section('bewertung-bereich', [
|
||||
$row('bewertung-zeile', [
|
||||
$column('bewertung-text', [
|
||||
$kleinzeile('bewertung-klein', 'Immobilienbewertung', '@himmel'),
|
||||
$heading('bewertung-titel', 'Was ist Ihre Immobilie wert?', 2, $h2Hell),
|
||||
$text('bewertung-satz', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Für eine erste Einschätzung genügen uns wenige Angaben:', $fliess('@auf-dunkel', '28px', '520px')),
|
||||
$haken('bewertung-punkt-1', '<strong>Art und Vorhaben</strong> – Wohnung, Haus oder Grundstück, Verkauf oder Vermietung', '#dbe5ee', '@logo-blau'),
|
||||
$haken('bewertung-punkt-2', '<strong>Eckdaten</strong> – Wohnfläche, Baujahr und Zimmer', '#dbe5ee', '@logo-blau'),
|
||||
$haken('bewertung-punkt-3', '<strong>Ausstattung</strong> – Einbauküche und Parkmöglichkeiten', '#dbe5ee', '@logo-blau'),
|
||||
$zeile('bewertung-knoepfe', [
|
||||
$knopf('bewertung-knopf', 'Zum Bewertungsformular', '/immobilienbewertung', 'weiss'),
|
||||
$knopf('bewertung-anrufen', $telefon, $tel, 'hell', [], 'phone'),
|
||||
], '14px', ['flexWrap' => 'wrap', 'margin' => ['top' => '22px']]),
|
||||
]),
|
||||
$column('bewertung-bild-spalte', [
|
||||
$bild('bewertung-bild', $medien['bewertung'], 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch', [
|
||||
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '480px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
|
||||
'mobile' => ['imageHeight' => '280px'],
|
||||
], null, '& img{display:block;width:100%;box-shadow:18px 18px 0 #0b6aa2}'),
|
||||
], ['desktop' => ['padding' => ['right' => '22px', 'bottom' => '22px']]]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '88px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '112px', 'bottom' => '112px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
], 'bewertung', "position:relative;overflow:hidden\n&::after{content:'';position:absolute;right:-120px;top:-120px;width:360px;height:360px;border:2px solid rgba(46,168,224,.18);border-radius:50%;pointer-events:none}"),
|
||||
|
||||
// 5 – Kontakt: Blau, große Telefonnummer, Knopf zur Kontaktseite.
|
||||
$section('kontakt-bereich', [
|
||||
$row('kontakt-zeile', [
|
||||
$column('kontakt-text', [
|
||||
$heading('kontakt-titel', 'Sprechen Sie mit uns.', 2, [...$h2Hell, 'desktop' => [...$h2Hell['desktop'], 'fontSize' => '40px', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
|
||||
$text('kontakt-satz', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Rufen Sie an oder schreiben Sie uns.', $fliess('#e3f1f9', '0px', '560px')),
|
||||
]),
|
||||
$zeile('kontakt-wege', [
|
||||
$knopf('kontakt-anrufen', $telefon, $tel, 'weiss', [], 'phone'),
|
||||
$knopf('kontakt-schreiben', 'Nachricht schreiben', '/kontakt', 'hell'),
|
||||
], '14px', ['flexWrap' => 'wrap', 'justifyContent' => 'flex-end'], ['justifyContent' => 'flex-start']),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '3 2'],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@blau', 'color' => '@papier', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '48px']],
|
||||
], 'kontakt'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Hero-Text steigt beim Laden auf; der Abschnittshintergrund ist das LCP-Element und bleibt ruhig.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-satz' => ['rise', 'medium'],
|
||||
'hero-knoepfe' => ['rise', 'medium'],
|
||||
'hero-fakten' => ['fade', 'long'],
|
||||
'leistungen-kopf' => ['rise'],
|
||||
'leistung-1' => ['rise'],
|
||||
'leistung-2' => ['rise', 'short'],
|
||||
'leistung-3' => ['rise', 'medium'],
|
||||
'suche-bild-spalte' => ['left'],
|
||||
'suche-text' => ['rise', 'short'],
|
||||
'bewertung-text' => ['rise'],
|
||||
'bewertung-bild-spalte' => ['right', 'short'],
|
||||
'kontakt-zeile' => ['rise'],
|
||||
]);
|
||||
|
||||
$homeId = $anlegen('home', $startseite, 'EBI Immobilien Karlsruhe – Immobilien kaufen, mieten und bewerten lassen', $medien['karlsruhe']['id']);
|
||||
|
||||
/** Der Seitenkopf der Unterseiten: Quartiersfoto, stark abgedunkelt, Kleinzeile, Titel, Vorspann. */
|
||||
$seitenkopf = static fn (string $praefix, string $klein, string $titel, string $vorspann) => $section($praefix.'-kopf', [
|
||||
$kleinzeile($praefix.'-klein', $klein, '@himmel'),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '54px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '18px']],
|
||||
'mobile' => ['fontSize' => '36px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.78,
|
||||
'color' => '@papier', 'padding' => ['top' => '88px', 'bottom' => '80px'], 'borderWidth' => $nurUnten('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
|
||||
'mobile' => ['padding' => ['top' => '52px', 'bottom' => '48px']],
|
||||
]);
|
||||
|
||||
// Das Formular in einer weißen Karte: Felder auf Sand, Fokus in Logo-Blau, Schrittanzeige lesbar.
|
||||
$formularCss = '& .block-form{max-width:none}
|
||||
& .form-field label{font-size:15px;font-weight:700;color:#14273a}
|
||||
& .form-field input{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
|
||||
& .form-field select{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
|
||||
& .form-field textarea{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:130px}
|
||||
& .form-field input:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .form-field select:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .form-field textarea:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
|
||||
& .field-note{font-size:13px;color:#4d5966;opacity:1}
|
||||
& .form-step-intro{color:#4d5966;opacity:1}
|
||||
& .form-progress-step{color:#4d5966;opacity:1}
|
||||
& .form-choice{display:flex;flex-wrap:wrap;gap:10px}
|
||||
& .form-choice .check-row{gap:10px;align-items:center;background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:11px 16px;transition:border-color .2s,background-color .2s}
|
||||
& .form-choice .check-row:hover{border-color:#0b6aa2;background:#fff}
|
||||
& .check-row label{font-weight:500;font-size:15px;color:#14273a}
|
||||
& .check-row input{accent-color:#0b6aa2;min-height:0;width:18px;height:18px;padding:0;margin:0}
|
||||
& .cms-button{min-height:54px;font-size:16px;transition:transform .2s ease,background-color .2s ease}
|
||||
& .cms-button:hover{transform:translateY(-2px)}';
|
||||
|
||||
$karte = [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('44px'), 'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#14273a1f'],
|
||||
'mobile' => ['padding' => ['top' => '26px', 'right' => '20px', 'bottom' => '28px', 'left' => '20px']],
|
||||
];
|
||||
|
||||
/** Die Kontaktkarte für die Seitenspalte: Navy, Telefon und E-Mail. */
|
||||
$kontaktKarte = static fn (string $praefix) => $column($praefix.'-kontakt', [
|
||||
$heading($praefix.'-kontakt-titel', 'Lieber direkt sprechen?', 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '16px']]]),
|
||||
$zeile($praefix.'-kontakt-telefon', [
|
||||
$symbol($praefix.'-kontakt-telefon-icon', 'phone', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-telefon-text', '<a href="'.$tel.'">'.$telefon.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;font-weight:700}\n& a:hover{color:#7fd0f2}"),
|
||||
], '12px', ['margin' => ['bottom' => '10px']]),
|
||||
$zeile($praefix.'-kontakt-mail', [
|
||||
$symbol($praefix.'-kontakt-mail-icon', 'mail', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;overflow-wrap:anywhere}\n& a:hover{color:#7fd0f2}"),
|
||||
], '12px', ['margin' => ['bottom' => '10px']]),
|
||||
$zeile($praefix.'-kontakt-ort', [
|
||||
$symbol($praefix.'-kontakt-ort-icon', 'pin', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
|
||||
$text($praefix.'-kontakt-ort-text', $strasse.', '.$plz.' '.$ort, ['desktop' => ['fontSize' => '15px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
|
||||
], '12px', ['alignItems' => 'flex-start']),
|
||||
], ['desktop' => ['backgroundColor' => '@tinte', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px')]]);
|
||||
|
||||
/** Eine Formularseite: Kopf, Formularkarte links, Seitenspalte mit Hinweisen und Kontakt rechts. */
|
||||
$formularseite = static function (string $praefix, string $klein, string $titel, string $vorspann, string $kartentitel, string $formular, array $seitenspalte) use ($seitenkopf, $section, $row, $column, $heading, $text, $karte, $formularCss, $kontaktKarte, $linkCss, $zweiSpalten): array {
|
||||
return [
|
||||
$seitenkopf($praefix, $klein, $titel, $vorspann),
|
||||
$section($praefix, [
|
||||
$row($praefix.'-zeile', [
|
||||
$column($praefix.'-karte', [
|
||||
$heading($praefix.'-karte-titel', $kartentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '28px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '24px']], 'mobile' => ['fontSize' => '24px']]),
|
||||
['id' => $praefix.'-formular', 'type' => 'form', 'props' => ['form' => $formular], 'css' => $formularCss],
|
||||
$text($praefix.'-hinweis', 'Ihre Angaben verwenden wir nur für Ihre Anfrage. Mehr dazu in der <a href="/datenschutz">Datenschutzerklärung</a>.', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']]], $linkCss),
|
||||
], $karte),
|
||||
$column($praefix.'-seite', [...$seitenspalte, $kontaktKarte($praefix)], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'gap' => '24px']]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '2 1'],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '64px', 'bottom' => '100px']],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'bottom' => '60px']],
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
/** Hinweiskarte der Seitenspalte: Titel und Haken. */
|
||||
$hinweis = static fn (string $id, string $titel, array $punkte) => $column($id, [
|
||||
$heading($id.'-titel', $titel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '18px']]]),
|
||||
...array_map(static fn (int $i, string $p) => $haken($id.'-punkt-'.$i, $p, '@text', '@blau'), array_keys($punkte), $punkte),
|
||||
], ['desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px'), 'borderWidth' => $nurOben('3px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau']]);
|
||||
|
||||
$anlegen('suchauftrag', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Suchauftrag',
|
||||
'description' => 'Ihre Traumimmobilie war noch nicht dabei? Erstellen Sie einen Suchauftrag bei EBI Immobilien in Karlsruhe – wir melden uns, sobald etwas Passendes dabei ist.',
|
||||
'blocks' => $formularseite('suchauftrag', 'Immobilien-Suchauftrag', 'Suchauftrag erstellen', 'Ihre Traumimmobilie haben Sie noch nicht gefunden? Wir unterstützen Sie gern bei Ihrer Suche – sagen Sie uns, was Sie sich wünschen.', 'Was suchen Sie?', 'suchauftrag', [
|
||||
$hinweis('suchauftrag-ablauf', 'So geht es weiter', [
|
||||
'Wir sehen uns Ihre Wünsche persönlich an.',
|
||||
'Passt ein Objekt, melden wir uns bei Ihnen.',
|
||||
'Eine Besichtigung vereinbaren wir direkt mit Ihnen.',
|
||||
]),
|
||||
]),
|
||||
], ['suchauftrag-karte' => ['rise'], 'suchauftrag-seite' => ['rise', 'short']]), 'Suchauftrag erstellen | '.$firma.' Karlsruhe');
|
||||
|
||||
$anlegen('immobilienbewertung', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
|
||||
'description' => 'Was ist Ihre Immobilie wert? EBI Immobilien bewertet Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Karlsruhe und Umgebung.',
|
||||
'blocks' => $formularseite('bewertung', 'Immobilienbewertung', 'Was ist Ihre Immobilie wert?', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Beantworten Sie dazu ein paar kurze Fragen.', 'Bewertung anfragen', 'bewertung', [
|
||||
$hinweis('bewertung-ablauf', 'Gut zu wissen', [
|
||||
'Drei kurze Schritte, ungefähre Angaben genügen.',
|
||||
'Für Verkauf und Vermietung.',
|
||||
'Wir melden uns persönlich mit einer Einschätzung.',
|
||||
]),
|
||||
]),
|
||||
], ['bewertung-karte' => ['rise'], 'bewertung-seite' => ['rise', 'short']]), 'Immobilienbewertung Karlsruhe | '.$firma);
|
||||
|
||||
$anlegen('kontakt', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kontakt',
|
||||
'description' => 'So erreichen Sie EBI Immobilien in Karlsruhe: Telefon, E-Mail und Kontaktformular.',
|
||||
'blocks' => $formularseite('kontakt', 'Kontakt', 'Kontaktieren Sie uns', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Wir freuen uns auf Ihre Nachricht.', 'Ihre Nachricht', 'kontakt', [
|
||||
$hinweis('kontakt-themen', 'Wobei wir helfen', [
|
||||
'Kauf und Miete von Wohnungen und Häusern',
|
||||
'<a href="/suchauftrag">Suchauftrag</a> für Ihre Wunschimmobilie',
|
||||
'<a href="/immobilienbewertung">Bewertung</a> für Verkauf oder Vermietung',
|
||||
]),
|
||||
]),
|
||||
], ['kontakt-karte' => ['rise'], 'kontakt-seite' => ['rise', 'short']]), 'Kontakt | '.$firma.' Karlsruhe');
|
||||
|
||||
/** Eine Rechtsseite: Seitenkopf, dann Absätze mit Zwischenüberschriften. */
|
||||
$rechtsseite = static function (string $praefix, string $titel, string $vorspann, array $absaetze) use ($seitenkopf, $section, $column, $heading, $text, $linkCss, $nurOben): array {
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
if ('' !== $zwischentitel) {
|
||||
$punkte[] = $heading($praefix.'-h2-'.$i, $zwischentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '23px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'color' => '@tinte', 'margin' => ['top' => '36px', 'bottom' => '10px']]]);
|
||||
}
|
||||
$punkte[] = $text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss);
|
||||
}
|
||||
|
||||
return [
|
||||
$seitenkopf($praefix, 'Rechtliches', $titel, $vorspann),
|
||||
$section($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '820px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '32px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '12px', 'bottom' => '64px']]]),
|
||||
];
|
||||
};
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben von EBI Immobilien, Hopfenstraße 10b, 76185 Karlsruhe.',
|
||||
'blocks' => $rechtsseite('impressum', 'Impressum', 'Angaben gemäß § 5 DDG.', [
|
||||
['Anbieter', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort],
|
||||
['Kontakt', 'Telefon: <a href="'.$tel.'">'.$telefon.'</a><br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a>'],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Haftung für Inhalte', 'Die Inhalte dieser Website wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehmen wir jedoch keine Gewähr. Angaben zu Immobilien sind unverbindlich; maßgeblich ist das jeweilige Exposé.'],
|
||||
['Haftung für Links', 'Diese Website enthält Verknüpfungen zu Websites Dritter. Für deren Inhalte ist der jeweilige Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverstößen entfernen wir solche Links umgehend.'],
|
||||
['Urheberrecht', 'Die Inhalte dieser Website unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede Verwertung außerhalb der Grenzen des Urheberrechts bedarf der vorherigen schriftlichen Zustimmung. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.'],
|
||||
['Bildnachweis', 'Fotos: Pixabay (Pixabay-Lizenz).'],
|
||||
]),
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Welche Daten beim Besuch der Website von EBI Immobilien und bei einer Anfrage verarbeitet werden.',
|
||||
'blocks' => $rechtsseite('datenschutz', 'Datenschutzerklärung', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortlicher', $firma.', '.$strasse.', '.$plz.' '.$ort.'. Telefon <a href="'.$tel.'">'.$telefon.'</a>, E-Mail <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet unser Hostingdienstleister technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO. Mit dem Hostingdienstleister besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'],
|
||||
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Suchauftrag, Bewertung und Kontaktformular', 'Wenn Sie uns über ein Formular schreiben, verarbeiten wir Ihre Angaben – etwa Name, E-Mail-Adresse, Telefonnummer, Anschrift und die Angaben zu Ihrer Wunschimmobilie oder Ihrer Immobilie –, um Ihre Anfrage zu bearbeiten und Ihnen passende Angebote zu machen (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
|
||||
['5. Kontakt per E-Mail oder Telefon', 'Wenn Sie uns schreiben oder anrufen, verarbeiten wir Ihre Angaben, um Ihr Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'],
|
||||
['6. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen.'],
|
||||
['7. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', 'Fragen zum Datenschutz beantworten wir gern unter <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
|
||||
]),
|
||||
], 'Datenschutz | '.$firma);
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$section('fehler', [
|
||||
$kleinzeile('fehler-label', 'Fehler 404'),
|
||||
$heading('fehler-titel', 'Diese Seite gibt es leider nicht.', 1, [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '56px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '36px'],
|
||||
]),
|
||||
$text('fehler-text', 'Vielleicht wurde sie verschoben. Von der Startseite aus finden Sie alles Wichtige – oder Sie erstellen direkt einen Suchauftrag.', $fliess('@text', '32px', '520px')),
|
||||
$zeile('fehler-knoepfe', [
|
||||
$knopf('fehler-knopf', 'Zur Startseite', '/'),
|
||||
$knopf('fehler-suche', 'Suchauftrag erstellen', '/suchauftrag', 'rahmen', [], 'none'),
|
||||
], '14px', ['flexWrap' => 'wrap']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '112px', 'bottom' => '132px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// Alte WordPress-Adressen von ebi-immobilien.de.
|
||||
foreach ([
|
||||
'/contact-us' => '/kontakt',
|
||||
'/immobilienbewertungen' => '/immobilienbewertung',
|
||||
'/immobilien' => '/suchauftrag',
|
||||
'/property-search' => '/suchauftrag',
|
||||
] as $von => $nach) {
|
||||
$redirects->save($von, $nach, 301);
|
||||
}
|
||||
echo "Weiterleitungen angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menü, Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiteId = static fn (string $pfad): string => (string) $db->fetchOne('SELECT id FROM page WHERE path = ?', [$pfad]);
|
||||
$eintrag = static fn (string $label, string $pfad, array $extra = []): array => ['label' => $label, 'type' => 'page', 'pageId' => $seiteId($pfad), 'children' => [], ...$extra];
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $homeId, 'anchor' => 'leistungen', 'children' => []],
|
||||
$eintrag('Suchauftrag', 'suchauftrag'),
|
||||
$eintrag('Immobilienbewertung', 'immobilienbewertung'),
|
||||
$eintrag('Kontakt', 'kontakt'),
|
||||
// „Jetzt anrufen“ als Menü-Button (ADR 110); auf dem Telefon im aufgeklappten Menü (Umut, 13.10.2026).
|
||||
$eintrag('Jetzt anrufen', 'kontakt', [
|
||||
'type' => 'url', 'url' => $tel, 'pageId' => null,
|
||||
'icon' => 'phone', 'iconPosition' => 'before',
|
||||
'button' => [
|
||||
'id' => 'm'.substr(hash('sha256', 'ebi-immobilien-anrufen'), 0, 10), 'shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal',
|
||||
'fill' => '@blau', 'ink' => '@papier', 'hoverFill' => '@tinte', 'hoverInk' => '@papier',
|
||||
'mobile' => 'menu', 'iconMotion' => 'none',
|
||||
],
|
||||
]),
|
||||
], 0);
|
||||
echo "Menü angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'EBI Immobilien in Karlsruhe: Immobilien kaufen und mieten, Suchauftrag und Immobilienbewertung.',
|
||||
'email' => $mail, 'phone' => '+49 172 6110975',
|
||||
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
|
||||
'areaServed' => 'Karlsruhe',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['favicon']['id'],
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => true, 'postMetaHeader' => false,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
file_put_contents($root.'/var/ebi-immobilien-zugang.txt', "Verwaltung: Benutzer „umut“, Passwort: $passwort\n");
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Prüft, ob CMS und Zentrale dieselbe Sprache sprechen (ADR 123).
|
||||
*
|
||||
* Die Meldung kommt aus App\Ops\Zentrale, die Antwort aus Zentrale\Api - beide echt, nur ohne
|
||||
* Netz dazwischen: Registrierung mit Schlüssel, unterschriebene Folgemeldung, Auftrag abholen,
|
||||
* Ergebnis zurückmelden, fremde Unterschrift abweisen, zweite Installation mit fremder Kennung.
|
||||
*
|
||||
* Aufruf: php scripts/zentrale-check.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale as CmsZentrale;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
spl_autoload_register(static function (string $klasse): void {
|
||||
if (str_starts_with($klasse, 'Zentrale\\')) {
|
||||
require dirname(__DIR__).'/zentrale/app/'.substr($klasse, 9).'.php';
|
||||
}
|
||||
});
|
||||
|
||||
$tmp = str_replace('\\', '/', sys_get_temp_dir()).'/zentrale-check-'.bin2hex(random_bytes(4));
|
||||
mkdir($tmp.'/site/var', 0o775, true);
|
||||
file_put_contents($tmp.'/site/VERSION', "1.35.1\n");
|
||||
$pdo = Zentrale\Datenbank::oeffnen($tmp.'/zentrale.db');
|
||||
$api = new Zentrale\Api($pdo);
|
||||
|
||||
$fehler = 0;
|
||||
$pruefe = static function (bool $ok, string $was) use (&$fehler): void {
|
||||
echo ($ok ? 'ok ' : 'FEHLER ').$was."\n";
|
||||
$fehler += $ok ? 0 : 1;
|
||||
};
|
||||
|
||||
$ueberApi = static function (string $url, string $body, array $kopf) use ($api): array {
|
||||
$antwort = $api->melden($body, $kopf['X-KantSites-Signatur'] ?? '', '203.0.113.7');
|
||||
|
||||
return ['status' => $antwort['status'], 'body' => $antwort['body'], 'signatur' => (string) $antwort['signatur']];
|
||||
};
|
||||
|
||||
$installation = static function (string $wurzel, string $adresse, \Closure $senden): CmsZentrale {
|
||||
$db = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
$updater = new Updater($wurzel, 'sqlite:///:memory:', new CommandRunner(), $db);
|
||||
|
||||
return new CmsZentrale($wurzel, $updater, new ArchiveUpdate($wurzel, $updater, new Backup($wurzel, 'sqlite:///:memory:', $wurzel.'/var/media', $db, new CommandRunner()), new Download()), 'https://sites.example', $adresse, 'sqlite:///x', $senden);
|
||||
};
|
||||
|
||||
$site = $installation($tmp.'/site', 'https://kunde-eins.de', $ueberApi);
|
||||
$erste = $site->melden();
|
||||
$pruefe(null === $erste['auftrag'], 'Erste Meldung wird angenommen, kein Auftrag');
|
||||
$zeile = $pdo->query('SELECT * FROM installation')->fetch();
|
||||
$pruefe('kunde-eins.de' === ($zeile['domain'] ?? null) && '1.35.1' === $zeile['fassung'], 'Domain und Fassung stehen in der Zentrale');
|
||||
$ich = json_decode((string) file_get_contents($tmp.'/site/var/zentrale.json'), true);
|
||||
$pruefe(true === $ich['registriert'], 'Installation gilt danach als angemeldet');
|
||||
|
||||
$bestand = new Zentrale\Bestand($pdo);
|
||||
$pruefe(null === $bestand->beauftragen($zeile['id'], '1.36.0'), 'Update auf 1.36.0 lässt sich beauftragen');
|
||||
$zweite = $site->melden();
|
||||
$pruefe('1.36.0' === ($zweite['auftrag']['fassung'] ?? null) && 'update' === $zweite['auftrag']['aktion'], 'Folgemeldung holt den Auftrag ab');
|
||||
$pruefe(null === $site->melden()['auftrag'], 'Ein abgeholter Auftrag kommt nicht zweimal');
|
||||
$pruefe(null !== $bestand->beauftragen($zeile['id'], '1.36.0'), 'Während ein Auftrag läuft, gibt es keinen zweiten');
|
||||
|
||||
$site->auftragAngenommen($zweite['auftrag']['id'], '1.36.0');
|
||||
file_put_contents($tmp.'/site/VERSION', "1.36.0\n");
|
||||
$site->auftragErledigt(true, 'Fassung 1.36.0 läuft.');
|
||||
$site->melden();
|
||||
$auftrag = $pdo->query('SELECT * FROM auftrag')->fetch();
|
||||
$pruefe(1 === (int) $auftrag['ok'] && null !== $auftrag['erledigt'], 'Ergebnis kommt in der Zentrale an');
|
||||
$pruefe('1.36.0' === $pdo->query('SELECT fassung FROM installation')->fetchColumn(), 'Neue Fassung ist gemeldet');
|
||||
$pruefe(null === $site->offenerAuftrag(), 'Die Installation vergisst den quittierten Auftrag');
|
||||
|
||||
// Eine fremde Antwort: anderer Schlüssel unterschreibt.
|
||||
$gefaelscht = $installation($tmp.'/site', 'https://kunde-eins.de', static function (string $url, string $body) use ($ueberApi): array {
|
||||
$echt = $ueberApi($url, $body, []);
|
||||
|
||||
return [...$echt, 'signatur' => hash_hmac('sha256', $echt['body'], str_repeat('b', 64))];
|
||||
});
|
||||
try {
|
||||
$gefaelscht->melden();
|
||||
$pruefe(false, 'Unsignierte Meldung wird abgewiesen');
|
||||
} catch (\RuntimeException $e) {
|
||||
$pruefe(str_contains($e->getMessage(), '403'), 'Meldung ohne gültige Unterschrift weist die Zentrale mit 403 ab');
|
||||
}
|
||||
|
||||
// Jemand übernimmt die Kennung mit eigenem Schlüssel.
|
||||
$body = json_encode(['id' => $zeile['id'], 'adresse' => 'https://fremd.de', 'fassung' => '1.36.0', 'schluessel' => str_repeat('c', 64)]);
|
||||
$fremd = $api->melden($body, hash_hmac('sha256', $body, str_repeat('c', 64)), '198.51.100.1');
|
||||
$pruefe(403 === $fremd['status'], 'Fremder Schlüssel für eine bekannte Kennung wird abgewiesen');
|
||||
|
||||
// Die Zentrale hat die Installation vergessen: Sie meldet sich mit Schlüssel neu an.
|
||||
$pdo->exec('DELETE FROM installation');
|
||||
$site->melden();
|
||||
$pruefe(1 === (int) $pdo->query('SELECT COUNT(*) FROM installation')->fetchColumn(), 'Nach Verlust in der Zentrale meldet sich die Installation neu an');
|
||||
|
||||
// Umzug: dieselbe Kennung, andere Domain.
|
||||
$umzug = $installation($tmp.'/site', 'https://www.kunde-zwei.de', $ueberApi);
|
||||
$umzug->melden();
|
||||
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
|
||||
$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar');
|
||||
|
||||
// PHP-Fassungen mit bekannter Lücke fallen auf; gepatchte, unbekannte und Vorabfassungen nicht.
|
||||
$pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Bestand::phpLuecke('8.1.30'), 'Ungepatchtes und veraltetes PHP gilt als unsicher');
|
||||
$pruefe(null === Zentrale\Bestand::phpLuecke('8.4.27') && null === Zentrale\Bestand::phpLuecke('8.6.0RC4') && null === Zentrale\Bestand::phpLuecke(''), 'Gepatchtes, neueres und unbekanntes PHP gilt nicht als unsicher');
|
||||
$pruefe(array_key_exists('php_luecke', $bestand->alle(null)[0] ?? []), 'Die Übersicht kennt den PHP-Zustand');
|
||||
|
||||
// KantTresor (ADR 124): eigener Pfad, gleiches Verfahren, nie ein Auftrag. Die Gegenseite ist
|
||||
// Python (controlplane/zentrale.py in KantArchiv); hier wird sie mit denselben Feldern nachgestellt.
|
||||
$tresorId = bin2hex(random_bytes(16));
|
||||
$tresorSchluessel = bin2hex(random_bytes(32));
|
||||
$tresorMelden = static function (array $daten, ?string $schluessel = null) use ($api, $tresorSchluessel): array {
|
||||
$body = (string) json_encode($daten, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $api->meldenTresor($body, hash_hmac('sha256', $body, $schluessel ?? $tresorSchluessel), '198.51.100.4');
|
||||
};
|
||||
$tresorDaten = ['id' => $tresorId, 'adresse' => 'https://archiv.kunde-drei.de', 'fassung' => '1.82.0', 'laufzeit' => 'Python 3.12.7 · Django 5.2.8', 'datenbank' => 'postgresql', 'mandanten' => 3, 'stoerungen' => ['Objektspeicher'], 'speicher' => 93];
|
||||
$antwort = $tresorMelden([...$tresorDaten, 'schluessel' => $tresorSchluessel]);
|
||||
$pruefe(200 === $antwort['status'] && hash_equals(hash_hmac('sha256', $antwort['body'], $tresorSchluessel), (string) $antwort['signatur']), 'KantTresor-Meldung wird angenommen und unterschrieben beantwortet');
|
||||
$pruefe(!str_contains($antwort['body'], 'auftrag'), 'KantTresor bekommt nie einen Auftrag');
|
||||
$tresorZeile = $bestand->eine($tresorId, '1.82.0');
|
||||
$pruefe('kanttresor' === ($tresorZeile['produkt'] ?? null) && 3 === (int) $tresorZeile['mandanten'] && 'Objektspeicher' === $tresorZeile['stoerungen'], 'Mandanten und Störungen stehen in der Zentrale');
|
||||
$pruefe(true === $tresorZeile['gestoert'] && true === $tresorZeile['speicher_knapp'], 'Gestörter Dienst und knapper Speicher fallen auf');
|
||||
$pruefe(false === $tresorZeile['kann_update'] && null !== $bestand->beauftragen($tresorId, '1.83.0'), 'Für KantTresor lässt sich kein Update beauftragen');
|
||||
$pruefe(1 === count($bestand->alle(null, 'kanttresor')) && [] === array_filter($bestand->alle(null), static fn (array $i): bool => $i['id'] === $tresorId), 'KantSites- und KantTresor-Liste bleiben getrennt');
|
||||
$pruefe(200 === $tresorMelden([...$tresorDaten, 'stoerungen' => [], 'speicher' => 41])['status'] && false === $bestand->eine($tresorId, null)['gestoert'], 'Folgemeldung ohne Schlüssel, Störung behoben');
|
||||
$pruefe(403 === $tresorMelden($tresorDaten, str_repeat('0', 64))['status'], 'KantTresor-Meldung mit falscher Unterschrift wird abgewiesen');
|
||||
$pruefe(409 === $tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16))])['status'], 'Unbekannte KantTresor-Kennung ohne Schlüssel bekommt 409');
|
||||
$kantsitesId = (string) $pdo->query("SELECT id FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$kantsitesSchluessel = (string) $pdo->query("SELECT schluessel FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$pruefe(403 === $tresorMelden([...$tresorDaten, 'id' => $kantsitesId], $kantsitesSchluessel)['status'], 'Eine KantSites-Kennung kann nicht als KantTresor melden');
|
||||
$tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16)), 'schluessel' => $tresorSchluessel, 'fassung' => '1.83.1']);
|
||||
$pruefe('1.83.1' === $bestand->hoechsteFassung('kanttresor') && true === $bestand->eine($tresorId, '1.83.1')['veraltet'], 'Höchste gemeldete KantTresor-Fassung macht ältere zu veraltet');
|
||||
|
||||
echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
|
||||
exit($fehler > 0 ? 1 : 0);
|
||||
@@ -43,8 +43,10 @@ final class DocumentValidator
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
|
||||
public const V27_TYPES = ['language'];
|
||||
/** Schema v28: ein eigenes Video aus der Mediathek. */
|
||||
public const V28_TYPES = ['video'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 27;
|
||||
public const SCHEMA_VERSION = 28;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -155,7 +157,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 28 && in_array($type, self::V28_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -468,6 +470,22 @@ final class DocumentValidator
|
||||
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
||||
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
||||
}
|
||||
if ('video' === $type) {
|
||||
// Nur eigene Dateien: die Richtlinie der Seite erlaubt Medien von der eigenen Adresse,
|
||||
// und ein fremdes Video wäre ohnehin ein Fall für „Externer Inhalt“ mit Einwilligung.
|
||||
$this->videoPath($props['src'] ?? '');
|
||||
$this->videoPath($props['srcWebm'] ?? '');
|
||||
if ('' !== ($props['poster'] ?? '')) {
|
||||
$this->url($props['poster']);
|
||||
}
|
||||
$this->text($props['title'] ?? '', 160, true);
|
||||
$this->require(in_array($props['mode'] ?? 'player', ['player', 'loop'], true), 'Ein Video ist ein Spieler oder eine Schleife.');
|
||||
}
|
||||
if ('section' === $type && (isset($props['backgroundVideo']) || isset($props['backgroundVideoWebm']))) {
|
||||
$this->require($schemaVersion >= 28, 'Ein Hintergrundvideo braucht Schema v28.');
|
||||
$this->videoPath($props['backgroundVideo'] ?? '');
|
||||
$this->videoPath($props['backgroundVideoWebm'] ?? '');
|
||||
}
|
||||
if ('html' === $type) {
|
||||
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
|
||||
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
|
||||
@@ -567,6 +585,11 @@ final class DocumentValidator
|
||||
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
|
||||
}
|
||||
|
||||
private function videoPath(mixed $path): void
|
||||
{
|
||||
$this->require('' === $path || (is_string($path) && 1 === preg_match(VideoLibrary::PATH, $path)), 'Ein Video kommt aus der Mediathek.');
|
||||
}
|
||||
|
||||
private function url(mixed $url): void
|
||||
{
|
||||
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
|
||||
|
||||
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
*/
|
||||
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 = [
|
||||
'legalName' => '',
|
||||
'description' => '',
|
||||
@@ -32,6 +52,12 @@ final class SiteSettings
|
||||
'openingHours' => [],
|
||||
'areaServed' => [],
|
||||
'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,
|
||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||
@@ -240,6 +266,19 @@ final class SiteSettings
|
||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||
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['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||
@@ -512,6 +551,20 @@ final class SiteSettings
|
||||
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
|
||||
* while nothing is maintained: an empty Organization block would be noise, not data.
|
||||
@@ -522,9 +575,10 @@ final class SiteSettings
|
||||
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
||||
return null;
|
||||
}
|
||||
$lokal = $settings['localBusiness'] && $hasAddress;
|
||||
$data = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
|
||||
'@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
|
||||
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
||||
'url' => $origin.'/',
|
||||
];
|
||||
@@ -542,6 +596,14 @@ final class SiteSettings
|
||||
'addressCountry' => $settings['country'],
|
||||
], 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
|
||||
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
||||
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
||||
|
||||
@@ -47,6 +47,7 @@ final class Translations
|
||||
'icon' => ['label'],
|
||||
'progress' => ['label'],
|
||||
'embed' => ['title', 'hint'],
|
||||
'video' => ['title'],
|
||||
];
|
||||
|
||||
/** Felder der Seite selbst und ihre Höchstlänge. */
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Videos neben den Bildern und PDFs der Mediathek.
|
||||
*
|
||||
* Wie ein PDF wird ein Video nicht umgerechnet, sondern so ausgeliefert, wie es kam: Auf dem
|
||||
* Hosting gibt es kein ffmpeg, und PHP könnte das in der Laufzeit einer Anfrage auch nicht. Deshalb
|
||||
* die Prüfung am Dateianfang statt an der Endung und beim Ausliefern Kopfzeilen, die den Browser
|
||||
* nichts anderes darin sehen lassen als ein Video.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.mp4 oder <id>.webm - so gehen sie mit jeder
|
||||
* Sicherung und jedem Website-Paket mit.
|
||||
*/
|
||||
final class VideoLibrary
|
||||
{
|
||||
/** Ein Hintergrund oder ein kurzer Film. Für Längeres ist ein Videodienst der bessere Weg. */
|
||||
public const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Videos, so steht sie im Dokument. */
|
||||
public const PATH = '#^/media/[a-f0-9]{32}\.(?:mp4|webm)$#D';
|
||||
|
||||
private const EXTENSIONS = ['video/mp4' => 'mp4', 'video/webm' => 'webm'];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM video'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM video WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
/** Soll diese Datei als Video behandelt werden? Der Inhalt entscheidet, die Endung ist Rückfall. */
|
||||
public function claims(UploadedFile $file): bool
|
||||
{
|
||||
return null !== $this->mimeOf($file->getPathname())
|
||||
|| str_starts_with((string) $file->getMimeType(), 'video/')
|
||||
|| in_array(strtolower($file->getClientOriginalExtension()), ['mp4', 'm4v', 'webm', 'mov'], true);
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 64 MB pro Video; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
$mime = $this->mimeOf($file->getPathname());
|
||||
if (null === $mime) {
|
||||
throw new \InvalidArgumentException('Das ist kein abspielbares Video. Bitte eine MP4- oder WebM-Datei wählen; MOV spielen nicht alle Browser ab.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id, self::EXTENSIONS[$mime]);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Video konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Video', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'mime' => $mime, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('video', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE video SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Videos, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id, string $extension): ?string
|
||||
{
|
||||
$item = $this->find($id);
|
||||
if (null === $item || $extension !== self::EXTENSIONS[$item['mime']]) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id, $extension);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Typ nach den ersten Bytes. MP4 trägt ab Byte 4 das Kästchen „ftyp“, WebM beginnt mit dem
|
||||
* EBML-Kopf und nennt seinen Dokumenttyp gleich danach. MOV hat auch „ftyp“, aber die Marke
|
||||
* „qt“ - das spielen nicht alle Browser ab, also wird es abgewiesen statt still zu scheitern.
|
||||
*/
|
||||
private function mimeOf(string $path): ?string
|
||||
{
|
||||
$head = (string) file_get_contents($path, false, null, 0, 4096);
|
||||
if ('ftyp' === substr($head, 4, 4) && 'qt ' !== substr($head, 8, 4)) {
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
return str_starts_with($head, "\x1A\x45\xDF\xA3") && str_contains($head, 'webm') ? 'video/webm' : null;
|
||||
}
|
||||
|
||||
private function path(string $id, string $extension): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.'.$extension;
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'mime' => $row['mime'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.'.self::EXTENSIONS[$row['mime']],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use App\Ops\MailCheck;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
private Languages $languages,
|
||||
private PluginRuntime $plugins,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,7 +43,8 @@ final class FormController extends AbstractController
|
||||
{
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
|
||||
$page = $this->pages->findPublished($path);
|
||||
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
|
||||
$page = $this->pages->findPublished($path) ?? $this->plugins->page('/'.$path);
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
}
|
||||
@@ -77,8 +80,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));
|
||||
}
|
||||
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);
|
||||
if ($this->deliver($form, $checked['values'])) {
|
||||
if ($this->deliver($form, $checked['values'], $extra)) {
|
||||
$this->forms->markDelivered($submission);
|
||||
}
|
||||
}
|
||||
@@ -101,9 +107,12 @@ final class FormController extends AbstractController
|
||||
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 = [];
|
||||
foreach ($extra as $name => $text) {
|
||||
$lines[] = $name.': '.$text;
|
||||
}
|
||||
foreach ($form['fields'] as $field) {
|
||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||
if ('step' === $field['type']) {
|
||||
|
||||
@@ -113,6 +113,19 @@ final class MailController extends AbstractController
|
||||
return $this->backToMail($request);
|
||||
}
|
||||
|
||||
/** „Letzte Versuche“ leeren: var/log/mail.log und mail.log.1. */
|
||||
#[Route('/%kant.admin_path%/system/e-mail/protokoll', name: 'admin_mail_clear', methods: ['POST'])]
|
||||
public function clear(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->mail->clearLog();
|
||||
|
||||
return $this->backToMail($request, 'Das Protokoll ist leer.', 'ok');
|
||||
}
|
||||
|
||||
/** @param array{an: string, von: string} $eingabe */
|
||||
private function mailAdressFehler(array $eingabe): ?string
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -17,7 +18,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived), 'videos' => $this->videos->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -57,7 +58,11 @@ final class MediaController extends AbstractController
|
||||
try {
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
match (true) {
|
||||
$this->isPdf($file) => $this->documents->upload($file),
|
||||
$this->videos->claims($file) => $this->videos->upload($file),
|
||||
default => $this->media->upload($file),
|
||||
};
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
@@ -120,6 +125,31 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/video/{id}', name: 'admin_video_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateVideo(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->videos->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->videos->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Video wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Video wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
@@ -195,6 +225,36 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Videos fuer die Auswahl im Editor. */
|
||||
#[Route('/%kant.admin_path%/api/videos', methods: ['GET'])]
|
||||
public function listVideos(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->videos->list(false), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/videos/upload', methods: ['POST'])]
|
||||
public function uploadVideo(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->videos->upload($file)], 201);
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Video konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -324,6 +384,20 @@ final class MediaController extends AbstractController
|
||||
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'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Video, so wie es hochgeladen wurde.
|
||||
*
|
||||
* BinaryFileResponse beantwortet Range-Anfragen; ohne sie lässt Safari ein Video gar nicht
|
||||
* abspielen. Die Datei ändert sich nie, ihre Adresse ist an die Kennung gebunden.
|
||||
*/
|
||||
#[Route('/media/{id}.{extension}', name: 'video_serve', requirements: ['id' => '[a-f0-9]{32}', 'extension' => 'mp4|webm'], methods: ['GET'])]
|
||||
public function serveVideo(string $id, string $extension): Response
|
||||
{
|
||||
$path = $this->videos->file($id, $extension) ?? throw $this->createNotFoundException();
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'video/'.$extension, 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -29,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private 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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -78,6 +79,10 @@ final class PageController extends AbstractController
|
||||
if (null !== $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);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
@@ -313,6 +318,7 @@ final class PageController extends AbstractController
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$needsVideo = (bool) array_filter($documents, fn (array $doc): bool => $this->hasLoopVideo($doc['blocks'] ?? []));
|
||||
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
|
||||
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
|
||||
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
|
||||
@@ -404,6 +410,7 @@ final class PageController extends AbstractController
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsVideo' => $needsVideo,
|
||||
'needsMotion' => $needsMotion,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
@@ -442,7 +449,7 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsVideo || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $embeds['hosts'];
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -860,6 +867,21 @@ final class PageController extends AbstractController
|
||||
return $walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
/** Schleifenvideos brauchen video.js: es startet sie und gibt ihnen den Knopf zum Anhalten. */
|
||||
private function hasLoopVideo(array $blocks): bool
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$props = $block['props'] ?? [];
|
||||
$loop = 'video' === ($block['type'] ?? null) && 'loop' === ($props['mode'] ?? null) && '' !== ($props['src'] ?? '');
|
||||
$background = 'section' === ($block['type'] ?? null) && ('' !== ($props['backgroundVideo'] ?? '') || '' !== ($props['backgroundVideoWebm'] ?? ''));
|
||||
if ($loop || $background || $this->hasLoopVideo($block['children'] ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasType(array $document, string $type): bool
|
||||
{
|
||||
$found = false;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
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.
|
||||
$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'])]
|
||||
@@ -36,11 +43,13 @@ final class PluginAdminController extends AbstractController
|
||||
}
|
||||
try {
|
||||
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');
|
||||
}
|
||||
$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');
|
||||
} 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');
|
||||
}
|
||||
|
||||
#[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
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SeoController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
public function __construct(private \App\Plugin\PluginRuntime $plugins,
|
||||
private Connection $db,
|
||||
private SiteSettings $settings,
|
||||
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 = $this->plugins->applyFilters('sitemap.entries', $entries, $origin);
|
||||
|
||||
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $settings,
|
||||
'businessTypes' => SiteSettings::BUSINESS_TYPES,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
@@ -18,7 +17,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -30,7 +28,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private Funktionspruefung $pruefung, private SitePackage $packages, private HttpsRedirect $https)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -272,28 +270,10 @@ final class SystemController extends AbstractController
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefen(...));
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
|
||||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite
|
||||
* als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut.
|
||||
*/
|
||||
private function pruefen(): ?string
|
||||
{
|
||||
$home = $this->settings->homePath();
|
||||
if (null === $this->pages->findPublished($home)) {
|
||||
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
|
||||
}
|
||||
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
|
||||
if ($antwort->getStatusCode() >= 400) {
|
||||
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wie sendAndStop, nur als JSON für die Systemseite. */
|
||||
private function sendJsonAndStop(array $daten): never
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Schließt ein Update ab, das die Zentrale angestoßen hat (ADR 123).
|
||||
*
|
||||
* Aus der Verwaltung ruft die Systemseite den zweiten Schritt selbst auf. Bei einem Auftrag der
|
||||
* Zentrale sitzt niemand davor. Dann übernimmt die nächste Anfrage: Sie startet mit dem neuen
|
||||
* Code - genau das, was die Migrationen brauchen -, führt sie aus, prüft die Startseite und gibt
|
||||
* die Website frei oder schaltet zurück. Der Besucher, der sie auslöst, wartet so lange und
|
||||
* bekommt danach die Seite in der neuen Fassung.
|
||||
*
|
||||
* Vor der Wartungsseite (Priorität 40), weil die sonst jede Anfrage mit 503 beendet. Nur bei
|
||||
* einer Marke mit `fern`: Ein Update aus der Verwaltung schließt die Verwaltung ab.
|
||||
*
|
||||
* Der Prozess, der die Dateien getauscht hat, kommt hier nicht mehr vorbei: Seine Anfrage ist zu
|
||||
* dem Zeitpunkt längst über diesen Punkt hinaus.
|
||||
*/
|
||||
final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ArchiveUpdate $archive,
|
||||
private Installation $installation,
|
||||
private Updater $updater,
|
||||
private Funktionspruefung $pruefung,
|
||||
private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::REQUEST => ['onRequest', 48]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
// Der übliche Fall kostet eine Dateiprüfung.
|
||||
if (!$event->isMainRequest() || !is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) {
|
||||
return;
|
||||
}
|
||||
$offen = $this->archive->pending();
|
||||
if (true !== ($offen['fern'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
// Gleichzeitige Anfragen: Eine schließt ab, die anderen sehen die Wartungsseite.
|
||||
$sperre = $this->updater->lock();
|
||||
if (null === $sperre) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (true !== ($this->archive->pending()['fern'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$this->archive->finish($this->installation, $this->pruefung);
|
||||
} catch (\Throwable) {
|
||||
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
|
||||
} finally {
|
||||
flock($sperre, LOCK_UN);
|
||||
fclose($sperre);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,21 @@ final class ArchiveUpdate
|
||||
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzeichnet den offenen zweiten Schritt als von der Zentrale angestoßen (ADR 123).
|
||||
*
|
||||
* Dann wartet niemand in der Verwaltung, der ihn aufruft; die nächste Anfrage mit dem neuen
|
||||
* Code schließt ihn ab (@see RemoteUpdateFinishSubscriber).
|
||||
*/
|
||||
public function markiereFern(): void
|
||||
{
|
||||
$offen = $this->pending();
|
||||
if (null === $offen) {
|
||||
return;
|
||||
}
|
||||
@file_put_contents($this->root.'/'.self::MARKE, json_encode([...$offen, 'fern' => true], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite als
|
||||
* Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht.
|
||||
*
|
||||
* Der Abschluss eines Updates ruft sie nach den Migrationen auf, aus der Verwaltung und nach
|
||||
* einem Auftrag der Zentrale. Sie braucht eine laufende Hauptanfrage: Die Unteranfrage hängt sich
|
||||
* an deren Request-Stack.
|
||||
*/
|
||||
class Funktionspruefung
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private SiteSettings $settings, private HttpKernelInterface $kernel)
|
||||
{
|
||||
}
|
||||
|
||||
/** null heißt: alles gut. Sonst der Grund, warum die Fassung nicht läuft. */
|
||||
public function __invoke(): ?string
|
||||
{
|
||||
$home = $this->settings->homePath();
|
||||
if (null === $this->pages->findPublished($home)) {
|
||||
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
|
||||
}
|
||||
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
|
||||
if ($antwort->getStatusCode() >= 400) {
|
||||
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,16 @@ final class MailCheck
|
||||
return $eintraege;
|
||||
}
|
||||
|
||||
/** Protokoll leeren: die Datei und ihre ältere Hälfte. */
|
||||
public function clearLog(): void
|
||||
{
|
||||
foreach ([$this->logFile, $this->logFile.'.1'] as $datei) {
|
||||
if (is_file($datei)) {
|
||||
@unlink($datei);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function logFile(): string
|
||||
{
|
||||
return $this->logFile;
|
||||
|
||||
@@ -62,6 +62,7 @@ final class Scheduler
|
||||
private Connection $db,
|
||||
private iterable $tasks,
|
||||
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
|
||||
* 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
|
||||
{
|
||||
$now ??= self::now();
|
||||
@@ -396,6 +405,9 @@ final class Scheduler
|
||||
foreach ($this->tasks as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
foreach ($this->plugins?->tasks() ?? [] as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
ksort($registry);
|
||||
|
||||
return $this->registry = $registry;
|
||||
|
||||
@@ -29,13 +29,13 @@ final class SitePackage
|
||||
public const MEDIA = 'medien/';
|
||||
|
||||
/** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */
|
||||
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
|
||||
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'video', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
|
||||
|
||||
/** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */
|
||||
public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret'];
|
||||
|
||||
/** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */
|
||||
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf)|favicon-\d{1,4}\.png)$/D';
|
||||
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf|mp4|webm)|favicon-\d{1,4}\.png)$/D';
|
||||
|
||||
private const MAX_FILES = 5000;
|
||||
private const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
||||
@@ -322,6 +322,8 @@ final class SitePackage
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pakete von vor Schema v28 kennen die Tabelle noch nicht; sie sind trotzdem vollständig.
|
||||
$tables['video'] ??= [];
|
||||
foreach (self::CONTENT_TABLES as $table) {
|
||||
if (!isset($tables[$table])) {
|
||||
throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table));
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale;
|
||||
|
||||
/**
|
||||
* Meldet sich bei der Zentrale und führt einen dort angestoßenen Auftrag aus (ADR 123).
|
||||
*
|
||||
* Der einzige Auftrag ist „Update auf Fassung X“. Er läuft über denselben Weg wie der Knopf in
|
||||
* der Verwaltung: Sicherung, Archiv mit Prüfsumme, Dateien tauschen. Den zweiten Schritt - die
|
||||
* Migrationen mit dem neuen Code - erledigt die nächste Anfrage (@see RemoteUpdateFinishSubscriber);
|
||||
* diese Aufgabe stößt sie gleich selbst an, damit die Wartungsseite nicht bis zum nächsten
|
||||
* Besucher stehen bleibt.
|
||||
*/
|
||||
final class ZentraleTask implements Task
|
||||
{
|
||||
/** Zeitgrenze für den Anstoß des zweiten Schritts; die Migrationen laufen in dieser Anfrage. */
|
||||
private const ABSCHLUSS_SEKUNDEN = 300;
|
||||
|
||||
/** @param (\Closure(string): void)|null $anstossen ruft die Website einmal auf; Tests setzen ihn */
|
||||
public function __construct(
|
||||
private Zentrale $zentrale,
|
||||
private ArchiveUpdate $archive,
|
||||
private Updater $updater,
|
||||
private string $siteOrigin,
|
||||
private string $projectDir,
|
||||
private ?\Closure $anstossen = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'zentrale';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bei der Zentrale melden';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Meldet Adresse und Fassung an sites.kant-it.de und holt dort angestoßene Updates ab. Ohne sie fehlt die Website in der Übersicht, und ein Update aus der Ferne kommt nicht an.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$grund = $this->zentrale->grundInaktiv();
|
||||
if (null !== $grund) {
|
||||
return $grund;
|
||||
}
|
||||
$this->ergebnisNachtragen();
|
||||
$antwort = $this->zentrale->melden();
|
||||
$auftrag = $antwort['auftrag'];
|
||||
if (null === $auftrag) {
|
||||
return sprintf('Gemeldet: Fassung %s.', $this->updater->version());
|
||||
}
|
||||
|
||||
return $this->ausfuehren($auftrag['id'], $auftrag['aktion'], $auftrag['fassung']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Auftrag, dessen zweiter Schritt in einer anderen Anfrage lief: Was ist daraus geworden?
|
||||
* Steht nichts mehr aus, verrät es der Fortschritt des Updates.
|
||||
*/
|
||||
private function ergebnisNachtragen(): void
|
||||
{
|
||||
$auftrag = $this->zentrale->offenerAuftrag();
|
||||
if (null === $auftrag || array_key_exists('ok', $auftrag) || null !== $this->archive->pending()) {
|
||||
return;
|
||||
}
|
||||
$status = $this->archive->status() ?? [];
|
||||
if ($status['laeuft'] ?? false) {
|
||||
return;
|
||||
}
|
||||
$phase = (string) ($status['phase'] ?? '');
|
||||
$fassung = (string) ($auftrag['fassung'] ?? '');
|
||||
$ok = 'fertig' === $phase || version_compare($this->updater->version(), $fassung, '>=');
|
||||
$this->zentrale->auftragErledigt($ok, (string) ($status['meldung'] ?? ($ok ? 'Fassung '.$this->updater->version().' läuft.' : 'Ohne Ergebnis beendet.')));
|
||||
}
|
||||
|
||||
private function ausfuehren(int $id, string $aktion, string $fassung): string
|
||||
{
|
||||
$this->zentrale->auftragAngenommen($id, $fassung);
|
||||
$ablehnen = function (string $grund): string {
|
||||
$this->zentrale->auftragErledigt(false, $grund);
|
||||
$this->zentrale->melden();
|
||||
|
||||
return 'Auftrag abgelehnt: '.$grund;
|
||||
};
|
||||
if ('update' !== $aktion) {
|
||||
return $ablehnen(sprintf('Unbekannter Auftrag „%s“.', $aktion));
|
||||
}
|
||||
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $fassung)) {
|
||||
return $ablehnen('Die Fassung hat nicht die Form 1.2.3.');
|
||||
}
|
||||
if (version_compare($fassung, $this->updater->version(), '<=')) {
|
||||
$this->zentrale->auftragErledigt(true, sprintf('Bereits auf Fassung %s.', $this->updater->version()));
|
||||
$this->zentrale->melden();
|
||||
|
||||
return 'Auftrag erledigt: Diese Installation ist schon auf dem Stand.';
|
||||
}
|
||||
if (is_dir($this->projectDir.'/.git')) {
|
||||
return $ablehnen('Diese Installation ist eine Git-Arbeitskopie; sie wird über Git aktualisiert, nicht aus der Ferne.');
|
||||
}
|
||||
if (null !== $this->archive->pending() || is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) {
|
||||
return $ablehnen('Hier läuft gerade ein Update oder die Wartungsseite ist an.');
|
||||
}
|
||||
|
||||
// Ein Update dauert länger als eine gewöhnliche Aufgabe. Die Antwort an den Besucher ist
|
||||
// da schon unterwegs; abbrechen darf der Lauf jetzt nicht mehr.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->run($fassung);
|
||||
if (!($ergebnis['migrate'] ?? false)) {
|
||||
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
|
||||
$this->zentrale->auftragErledigt($ergebnis['ok'], (string) $letzter['detail']);
|
||||
$this->zentrale->melden();
|
||||
|
||||
return ($ergebnis['ok'] ? 'Auftrag erledigt: ' : 'Update gescheitert: ').$letzter['detail'];
|
||||
}
|
||||
|
||||
// Die neuen Dateien liegen; die Migrationen gehören in eine Anfrage mit dem neuen Code.
|
||||
$this->archive->markiereFern();
|
||||
$this->anstossen();
|
||||
$this->ergebnisNachtragen();
|
||||
try {
|
||||
$this->zentrale->melden();
|
||||
} catch (\Throwable) {
|
||||
// Das Ergebnis bleibt in var/zentrale.json und geht mit der nächsten Meldung raus.
|
||||
}
|
||||
|
||||
return null === $this->archive->pending()
|
||||
? sprintf('Update auf %s aus der Ferne ausgeführt.', $fassung)
|
||||
: sprintf('Dateien für %s getauscht; die Migrationen laufen mit der nächsten Anfrage.', $fassung);
|
||||
}
|
||||
|
||||
/** Ruft die Website einmal auf. Gelingt das nicht, schließt der nächste Besucher ab. */
|
||||
private function anstossen(): void
|
||||
{
|
||||
$adresse = rtrim($this->siteOrigin, '/').'/';
|
||||
if (null !== $this->anstossen) {
|
||||
($this->anstossen)($adresse);
|
||||
|
||||
return;
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($adresse);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => self::ABSCHLUSS_SEKUNDEN,
|
||||
CURLOPT_USERAGENT => 'KantSites-Abschluss',
|
||||
]);
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
||||
|
||||
return;
|
||||
}
|
||||
$kontext = stream_context_create(['http' => ['timeout' => self::ABSCHLUSS_SEKUNDEN, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Abschluss\r\n"]]);
|
||||
@file_get_contents($adresse, false, $kontext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Die Meldung an die Zentrale (ADR 123): welche Domain, welche Fassung - und ob ein Auftrag wartet.
|
||||
*
|
||||
* Jede Installation meldet sich regelmäßig bei sites.kant-it.de. Dort steht die Liste aller
|
||||
* Installationen; dort lässt sich ein Update anstoßen. Die Zentrale verbindet sich nie selbst mit
|
||||
* einer Website: Sie merkt sich den Auftrag, und die Website holt ihn mit der nächsten Meldung ab.
|
||||
* Damit braucht keine Installation eine Tür, die sich von außen öffnen lässt.
|
||||
*
|
||||
* Was gemeldet wird, steht in payload(): Adresse, Fassung, PHP, Datenbankart, Updatestand. Keine
|
||||
* Inhalte, keine Besucher, keine Zugangsdaten.
|
||||
*
|
||||
* Die Echtheit: Beim ersten Kontakt schickt die Installation einen zufälligen Schlüssel mit (über
|
||||
* HTTPS). Danach trägt jede Meldung eine HMAC-Unterschrift damit, und jede Antwort der Zentrale
|
||||
* ebenso. Wer die Kennung einer Installation kennt, kann ohne den Schlüssel weder für sie melden
|
||||
* noch ihr einen Auftrag unterschieben. Ein Auftrag kann ohnehin nur eine veröffentlichte Fassung
|
||||
* mit passender Prüfsumme einspielen (@see ArchiveUpdate).
|
||||
*/
|
||||
class Zentrale
|
||||
{
|
||||
/** Kennung, Schlüssel und offener Auftrag dieser Installation. Bleibt bei Updates erhalten. */
|
||||
public const DATEI = 'var/zentrale.json';
|
||||
|
||||
private const PFAD = '/api/melden';
|
||||
|
||||
/** @var (\Closure(string, string, array<string, string>): array{status: int, body: string, signatur: string})|null */
|
||||
private ?\Closure $senden;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
private ArchiveUpdate $archive,
|
||||
private string $url,
|
||||
private string $siteOrigin,
|
||||
private string $databaseUrl,
|
||||
?\Closure $senden = null,
|
||||
) {
|
||||
$this->senden = $senden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet diese Installation? Nein, wenn die Zentrale abgeschaltet ist (ZENTRALE_URL=aus) oder
|
||||
* die Website nur auf diesem Rechner läuft - Entwicklungs- und Messinstallationen gehören
|
||||
* nicht in die Liste.
|
||||
*/
|
||||
public function aktiv(): bool
|
||||
{
|
||||
return null === $this->grundInaktiv();
|
||||
}
|
||||
|
||||
public function grundInaktiv(): ?string
|
||||
{
|
||||
if (!str_starts_with($this->url, 'https://')) {
|
||||
return 'Abgeschaltet (ZENTRALE_URL ist nicht gesetzt oder keine HTTPS-Adresse).';
|
||||
}
|
||||
$host = strtolower((string) parse_url($this->siteOrigin, PHP_URL_HOST));
|
||||
if ('' === $host || in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true) || str_ends_with($host, '.localhost') || str_ends_with($host, '.test') || str_ends_with($host, '.invalid')) {
|
||||
return sprintf('Keine Meldung: Die Website läuft lokal (%s).', '' === $host ? 'ohne SITE_ORIGIN' : $host);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt die Meldung und gibt den Auftrag zurück, falls einer wartet.
|
||||
*
|
||||
* @return array{auftrag: ?array{id: int, aktion: string, fassung: string}, registriert: bool}
|
||||
*
|
||||
* @throws \RuntimeException wenn die Zentrale nicht oder nicht echt antwortet
|
||||
*/
|
||||
public function melden(): array
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
$erneut = false;
|
||||
while (true) {
|
||||
$daten = $this->payload($ich);
|
||||
$body = json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
$antwort = $this->post(rtrim($this->url, '/').self::PFAD, $body, [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-KantSites-Signatur' => hash_hmac('sha256', $body, $ich['schluessel']),
|
||||
]);
|
||||
// Die Zentrale kennt diese Kennung nicht (mehr) - etwa nach einem Neuaufsetzen dort.
|
||||
// Dann einmal mit Schlüssel neu anmelden.
|
||||
if (409 === $antwort['status'] && !$erneut) {
|
||||
$ich['registriert'] = false;
|
||||
$erneut = true;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (200 !== $antwort['status']) {
|
||||
throw new \RuntimeException(sprintf('Die Zentrale antwortet mit %d. %s', $antwort['status'], $this->fehlertext($antwort['body'])));
|
||||
}
|
||||
if (!hash_equals(hash_hmac('sha256', $antwort['body'], $ich['schluessel']), $antwort['signatur'])) {
|
||||
throw new \RuntimeException('Die Antwort der Zentrale trägt keine gültige Unterschrift und wird verworfen.');
|
||||
}
|
||||
$inhalt = json_decode($antwort['body'], true);
|
||||
if (!is_array($inhalt) || true !== ($inhalt['ok'] ?? null)) {
|
||||
throw new \RuntimeException('Die Antwort der Zentrale ist unverständlich.');
|
||||
}
|
||||
|
||||
$ich['registriert'] = true;
|
||||
$ich['gemeldet'] = gmdate('c');
|
||||
// Ein gemeldetes Ergebnis ist angekommen; der Auftrag ist damit erledigt.
|
||||
if (isset($daten['auftrag']['ok'])) {
|
||||
unset($ich['auftrag']);
|
||||
}
|
||||
$this->speichern($ich);
|
||||
|
||||
$auftrag = $inhalt['auftrag'] ?? null;
|
||||
if (!is_array($auftrag) || !is_int($auftrag['id'] ?? null) || !is_string($auftrag['aktion'] ?? null) || !is_string($auftrag['fassung'] ?? null)) {
|
||||
$auftrag = null;
|
||||
}
|
||||
|
||||
return ['auftrag' => null === $auftrag ? null : ['id' => $auftrag['id'], 'aktion' => $auftrag['aktion'], 'fassung' => $auftrag['fassung']], 'registriert' => true];
|
||||
}
|
||||
|
||||
/** Merkt sich einen angenommenen Auftrag, bis sein Ergebnis gemeldet ist. */
|
||||
public function auftragAngenommen(int $id, string $fassung): void
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
$ich['auftrag'] = ['id' => $id, 'fassung' => $fassung, 'seit' => gmdate('c')];
|
||||
$this->speichern($ich);
|
||||
}
|
||||
|
||||
/** Hält das Ergebnis eines Auftrags fest; die nächste Meldung trägt es zur Zentrale. */
|
||||
public function auftragErledigt(bool $ok, string $meldung): void
|
||||
{
|
||||
$ich = $this->identitaet();
|
||||
if (!isset($ich['auftrag'])) {
|
||||
return;
|
||||
}
|
||||
$ich['auftrag']['ok'] = $ok;
|
||||
$ich['auftrag']['meldung'] = mb_substr($meldung, 0, 500);
|
||||
$this->speichern($ich);
|
||||
}
|
||||
|
||||
/** @return array{id?: int, fassung?: string, seit?: string, ok?: bool, meldung?: string}|null */
|
||||
public function offenerAuftrag(): ?array
|
||||
{
|
||||
return $this->identitaet()['auftrag'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was gemeldet wird.
|
||||
*
|
||||
* @param array<string, mixed> $ich
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payload(array $ich): array
|
||||
{
|
||||
$status = $this->archive->status();
|
||||
$daten = [
|
||||
'id' => $ich['id'],
|
||||
'adresse' => rtrim($this->siteOrigin, '/'),
|
||||
'fassung' => $this->updater->version(),
|
||||
'php' => PHP_VERSION,
|
||||
'datenbank' => strtolower((string) strtok($this->databaseUrl, ':')),
|
||||
// Über Git aktualisierte Installationen nimmt die Zentrale nicht in Auftrag.
|
||||
'art' => is_dir($this->root.'/.git') ? 'git' : 'archiv',
|
||||
'wartung' => is_file($this->root.'/'.Updater::MAINTENANCE_FILE),
|
||||
'update' => null === $status ? null : [
|
||||
'phase' => (string) ($status['phase'] ?? ''),
|
||||
'meldung' => mb_substr((string) ($status['meldung'] ?? ''), 0, 300),
|
||||
'zeit' => (string) ($status['aktualisiert'] ?? ''),
|
||||
],
|
||||
];
|
||||
if (!($ich['registriert'] ?? false)) {
|
||||
$daten['schluessel'] = $ich['schluessel'];
|
||||
}
|
||||
$auftrag = $ich['auftrag'] ?? null;
|
||||
if (is_array($auftrag)) {
|
||||
$daten['auftrag'] = array_key_exists('ok', $auftrag)
|
||||
? ['id' => $auftrag['id'], 'ok' => $auftrag['ok'], 'meldung' => $auftrag['meldung'] ?? '']
|
||||
: ['id' => $auftrag['id']];
|
||||
}
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function identitaet(): array
|
||||
{
|
||||
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::DATEI), true);
|
||||
if (is_array($daten) && is_string($daten['id'] ?? null) && is_string($daten['schluessel'] ?? null)) {
|
||||
return $daten;
|
||||
}
|
||||
$daten = ['id' => bin2hex(random_bytes(16)), 'schluessel' => bin2hex(random_bytes(32)), 'registriert' => false];
|
||||
$this->speichern($daten);
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function speichern(array $daten): void
|
||||
{
|
||||
$datei = $this->root.'/'.self::DATEI;
|
||||
@mkdir(dirname($datei), 0o775, true);
|
||||
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (false === @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) || !@rename($zwischen, $datei)) {
|
||||
@unlink($zwischen);
|
||||
throw new \RuntimeException(self::DATEI.' ist nicht schreibbar.');
|
||||
}
|
||||
}
|
||||
|
||||
private function fehlertext(string $body): string
|
||||
{
|
||||
$daten = json_decode($body, true);
|
||||
|
||||
return is_array($daten) && is_string($daten['fehler'] ?? null) ? mb_substr($daten['fehler'], 0, 200) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST über HTTPS, ohne Weiterleitungen: Eine Meldung, die woanders hin umgelenkt wird, soll
|
||||
* scheitern und nicht ankommen.
|
||||
*
|
||||
* @param array<string, string> $kopf
|
||||
*
|
||||
* @return array{status: int, body: string, signatur: string}
|
||||
*/
|
||||
private function post(string $url, string $body, array $kopf): array
|
||||
{
|
||||
if (null !== $this->senden) {
|
||||
return ($this->senden)($url, $body, $kopf);
|
||||
}
|
||||
$zeilen = ['User-Agent: KantSites/'.$this->updater->version()];
|
||||
foreach ($kopf as $name => $wert) {
|
||||
$zeilen[] = $name.': '.$wert;
|
||||
}
|
||||
$signatur = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => $zeilen,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HEADERFUNCTION => static function ($curl, string $zeile) use (&$signatur): int {
|
||||
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $treffer)) {
|
||||
$signatur = strtolower($treffer[1]);
|
||||
}
|
||||
|
||||
return strlen($zeile);
|
||||
},
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$fehler = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if (!is_string($antwort)) {
|
||||
throw new \RuntimeException('Die Zentrale ist nicht erreichbar: '.$fehler);
|
||||
}
|
||||
|
||||
return ['status' => $status, 'body' => $antwort, 'signatur' => $signatur];
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
throw new \RuntimeException('Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.');
|
||||
}
|
||||
$kontext = stream_context_create([
|
||||
'http' => ['method' => 'POST', 'header' => implode("\r\n", $zeilen), 'content' => $body, 'timeout' => 20, 'follow_location' => 0, 'ignore_errors' => true],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $kontext);
|
||||
$kopfzeilen = $http_response_header ?? [];
|
||||
if (!is_string($antwort) || [] === $kopfzeilen) {
|
||||
throw new \RuntimeException('Die Zentrale ist nicht erreichbar.');
|
||||
}
|
||||
preg_match('/^HTTP\/\S+\s+(\d{3})/', (string) $kopfzeilen[0], $treffer);
|
||||
foreach ($kopfzeilen as $zeile) {
|
||||
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $s)) {
|
||||
$signatur = strtolower($s[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => (int) ($treffer[1] ?? 0), 'body' => $antwort, 'signatur' => $signatur];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
|
||||
*
|
||||
* 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
|
||||
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
|
||||
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
|
||||
* Seitenvorlagen für den Editor. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
|
||||
* ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
|
||||
*
|
||||
* 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):
|
||||
@@ -144,6 +144,12 @@ final class PluginRegistry
|
||||
$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. */
|
||||
public function settingsOf(string $name): array
|
||||
{
|
||||
@@ -423,11 +429,15 @@ final class PluginRegistry
|
||||
}
|
||||
$routes = $this->routes($name, $data['routes'] ?? []);
|
||||
$presets = $this->presets($name, $data['presets'] ?? []);
|
||||
if ([] === $blocks && [] === $routes && [] === $presets) {
|
||||
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
|
||||
$main = $data['main'] ?? null;
|
||||
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
|
||||
|
||||
@@ -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'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Bildergalerie' => 'Image gallery',
|
||||
'Bild' => 'Image',
|
||||
'vergrößern' => 'enlarge',
|
||||
'Video' => 'Video',
|
||||
'Video anhalten' => 'Pause video',
|
||||
'Video abspielen' => 'Play video',
|
||||
'Ansehen' => 'View',
|
||||
'Weiterlesen' => 'Read more',
|
||||
'Weitere Beiträge' => 'More posts',
|
||||
@@ -131,6 +134,9 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Bildergalerie' => 'Resim galerisi',
|
||||
'Bild' => 'Resim',
|
||||
'vergrößern' => 'büyüt',
|
||||
'Video' => 'Video',
|
||||
'Video anhalten' => 'Videoyu durdur',
|
||||
'Video abspielen' => 'Videoyu oynat',
|
||||
'Ansehen' => 'İncele',
|
||||
'Weitere Beiträge' => 'Diğer yazılar',
|
||||
'Vorheriger Beitrag' => 'Önceki yazı',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Twig;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
|
||||
*/
|
||||
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
|
||||
// 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_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
|
||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Ein Versuch, gleich gebaut für „Letzte Versuche“ und „Alle Wege ausprobiert“: Zustand, worum es
|
||||
ging, wann - bei Fehlern der Hinweis vorn und die Meldung des Servers zugeklappt dahinter. #}
|
||||
<li class="mail-entry">
|
||||
<span class="pill {{ eintrag.ok ? 'pill-live' : 'pill-error' }}">{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</span>
|
||||
<div class="mail-entry-text">
|
||||
<p class="mail-entry-line">
|
||||
{% if titel is defined %}<strong>{{ titel }}</strong>{% else %}{{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} <span class="muted">· {{ eintrag.weg }}</span>{% endif %}{% endif %}
|
||||
</p>
|
||||
{% if eintrag.zeit is defined %}<p class="mail-entry-time">{{ eintrag.zeit }} UTC</p>{% endif %}
|
||||
{% if not eintrag.ok %}
|
||||
{% if eintrag.hinweis %}<p class="mail-entry-hint">{{ eintrag.hinweis }}</p>{% endif %}
|
||||
<details class="mail-entry-detail"><summary>Meldung des Servers</summary><pre>{{ eintrag.meldung }}</pre></details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
+105
-90
@@ -10,120 +10,135 @@
|
||||
{% set f = mailForm ?? {} %}
|
||||
{% set e = mail.einstellung %}
|
||||
{% set weg = f.weg ?? e.weg %}
|
||||
<div class="card" id="mail">
|
||||
{% set beschreibung = {
|
||||
smtp: 'Empfohlen. Ein echtes Postfach, unabhängig vom Hoster.',
|
||||
native: 'Über den Server. Der Absender muss zu einer Domain beim Hoster gehören.',
|
||||
'sendmail-t': 'Über den Server, falls mail() fehlt.',
|
||||
'sendmail-bs': 'Über den Server, bei manchen Hostern gesperrt.',
|
||||
} %}
|
||||
|
||||
{% if mailNotice %}
|
||||
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }}" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="card mail-form" id="mail" action="{{ path('admin_mail_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
|
||||
<div class="card-body">
|
||||
<dl class="facts">
|
||||
<dt>Eingestellt</dt>
|
||||
<dd>
|
||||
{% if mail.gueltig %}
|
||||
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
|
||||
{% else %}
|
||||
<span class="check-bad"><strong>Die Einstellung ist ungültig.</strong></span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Gespeichert in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>.{% if mail.quelle != '.env' %} Sie hat dort Vorrang; speichern lässt sich hier nichts, testen schon.{% endif %}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{% if mailNotice %}
|
||||
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }} mail-result" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if mailResult %}
|
||||
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
|
||||
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} <span>({{ mailResult.weg }})</span>{% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
|
||||
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
|
||||
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
|
||||
<div class="mail-current">
|
||||
<span class="muted">Eingestellt</span>
|
||||
{% if mail.gueltig %}
|
||||
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">als</span> <code>{{ mail.benutzer }}</code>{% endif %}
|
||||
{% else %}
|
||||
<strong class="danger-text">Die Einstellung ist ungültig.</strong>
|
||||
{% endif %}
|
||||
<span class="muted mail-current-source">in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>{% if mail.quelle != '.env' %} – hat Vorrang, speichern lässt sich hier nichts, testen schon{% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if mailResults %}
|
||||
<div class="mail-result">
|
||||
<p><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ (f.an ?? '') }}. Was angenommen wurde, kann gespeichert werden; angekommen ist es damit noch nicht, bitte im Postfach nachsehen.</span></p>
|
||||
<ul class="log-list">
|
||||
{% for r in mailResults %}
|
||||
<li class="{{ r.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<strong>{{ r.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ r.weg }}
|
||||
{% if not r.ok %}<br><code>{{ r.meldung }}</code>{% if r.hinweis %}<br><span class="muted">{{ r.hinweis }}</span>{% endif %}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="mail-form" action="{{ path('admin_mail_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<fieldset>
|
||||
<legend>Versandweg</legend>
|
||||
<label class="check-label" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}> <span>SMTP mit Postfach <span class="muted">– empfohlen, unabhängig vom Hoster</span></span></label>
|
||||
<fieldset class="card-section">
|
||||
<legend>Weg wählen</legend>
|
||||
<div class="option-list">
|
||||
<label class="option" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}><span><strong>SMTP mit Postfach</strong><span>{{ beschreibung.smtp }}</span></span></label>
|
||||
{% for key, w in mail.wege %}
|
||||
<label class="check-label" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}> {{ w.label }}</label>
|
||||
<label class="option" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}><span><strong>{{ w.label }}</strong><span>{{ beschreibung[key] ?? '' }}</span></span></label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="smtp-fields">
|
||||
<legend>SMTP</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-server">Server</label>
|
||||
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-port">Port</label>
|
||||
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-user">Benutzer</label>
|
||||
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-pass">Passwort</label>
|
||||
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
|
||||
</div>
|
||||
<fieldset class="card-section smtp-fields">
|
||||
<legend>SMTP-Zugang</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-server">Server</label>
|
||||
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
|
||||
</div>
|
||||
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Testmail</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-to">An</label>
|
||||
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-from">Absender</label>
|
||||
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-port">Port</label>
|
||||
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
|
||||
</div>
|
||||
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
|
||||
</fieldset>
|
||||
<div>
|
||||
<label for="mail-user">Benutzer</label>
|
||||
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-pass">Passwort</label>
|
||||
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="mail-actions">
|
||||
<fieldset class="card-section">
|
||||
<legend>Testmail</legend>
|
||||
<div class="mail-grid">
|
||||
<div>
|
||||
<label for="mail-to">An</label>
|
||||
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail-from">Absender</label>
|
||||
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
|
||||
<div class="button-row">
|
||||
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
|
||||
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
|
||||
{% if mail.quelle == '.env' %}<button type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>{% endif %}
|
||||
</div>
|
||||
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier. Gespeichert wird {{ mail.quelle == '.env' ? 'nur mit „Diesen Weg speichern“' : 'hier nichts, weil MAILER_DSN in der Server-Umgebung steht' }}.</p>
|
||||
</form>
|
||||
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier.</p>
|
||||
|
||||
{% if mailResult %}
|
||||
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
|
||||
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
|
||||
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
|
||||
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if mailResults %}
|
||||
<div class="mail-result">
|
||||
<p class="mail-result-head"><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ f.an ?? '' }}. Angenommen heißt noch nicht angekommen: bitte im Postfach nachsehen.</span></p>
|
||||
<ul class="mail-log">
|
||||
{% for r in mailResults %}
|
||||
{% include 'admin/_mail_entry.html.twig' with {eintrag: r, titel: r.weg} only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot form-foot">
|
||||
{% if mail.quelle == '.env' %}
|
||||
<button class="primary" type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>
|
||||
<span class="muted">Erst mit „Speichern“ versenden die Formulare auf dem gewählten Weg.</span>
|
||||
{% else %}
|
||||
<span class="muted">MAILER_DSN steht in der Server-Umgebung – speichern lässt sich hier nichts.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Letzte Versuche</h2><span class="muted">var/log/mail.log</span></div>
|
||||
<div class="card-head">
|
||||
<h2>Letzte Versuche</h2>
|
||||
{% if mail.protokoll is empty %}
|
||||
<span class="muted">var/log/mail.log</span>
|
||||
{% else %}
|
||||
<form action="{{ path('admin_mail_clear') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="text-button" type="submit">Protokoll leeren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if mail.protokoll is empty %}
|
||||
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
|
||||
{% else %}
|
||||
<ul class="log-list">
|
||||
<ul class="mail-log">
|
||||
{% for eintrag in mail.protokoll %}
|
||||
<li class="{{ eintrag.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<strong>{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ eintrag.zeit }} UTC · {{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} · {{ eintrag.weg }}{% endif %}
|
||||
{% if not eintrag.ok %}<br><code>{{ eintrag.meldung }}</code>{% if eintrag.hinweis %}<br><span class="muted">{{ eintrag.hinweis }}</span>{% endif %}{% endif %}
|
||||
</li>
|
||||
{% include 'admin/_mail_entry.html.twig' with {eintrag: eintrag} only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="field-help">Die letzten zehn Versuche aus <code>var/log/mail.log</code>, neueste zuerst. Zeiten in UTC.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %}
|
||||
{% block lead %}Alle Bilder, PDFs und Videos, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs und Videos bleiben, wie sie sind. PDFs lassen sich in jedem Linkziel wählen, Videos im Video-Baustein und als Hintergrund einer Sektion.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="dropzone" data-dropzone>
|
||||
<label class="dropzone-label" for="files">
|
||||
<span class="dropzone-icon" aria-hidden="true">▩</span>
|
||||
<strong>Bilder oder PDFs hierher ziehen</strong>
|
||||
<strong>Bilder, PDFs oder Videos hierher ziehen</strong>
|
||||
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
|
||||
</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf" multiple class="dropzone-input">
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf,video/mp4,video/webm,.mp4,.webm" multiple class="dropzone-input">
|
||||
</div>
|
||||
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB, MP4 oder WebM bis 64 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -39,6 +39,50 @@
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
|
||||
<section class="stack" aria-labelledby="videos-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="videos-heading">Videos <span class="count">{{ videos|length }}</span></h2>
|
||||
</div>
|
||||
{% if videos is empty %}
|
||||
<div class="empty">
|
||||
<h3>Keine Videos in der Mediathek.</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein MP4 oder WebM hoch. Danach lässt es sich im Video-Baustein oder als Hintergrund einer Sektion wählen.' : 'Ein Redakteur kann Videos hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for item in videos %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon row-icon-text" aria-hidden="true">{{ item.mime == 'video/webm' ? 'WEBM' : 'MP4' }}</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ item.src }}" target="_blank" rel="noopener">{{ item.title }}</a></h3>
|
||||
<p class="muted">{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · <code>{{ item.src }}</code>{% if item.archived %} · archiviert{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_video_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="video-title-{{ item.id }}">Titel</label>
|
||||
<input id="video-title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label class="check-label" for="video-archived-{{ item.id }}"><input id="video-archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Videos erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – eine Seite, die das Video zeigt, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um
|
||||
eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #}
|
||||
<section class="stack" aria-labelledby="documents-heading">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -56,6 +56,9 @@
|
||||
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% 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 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 %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -175,6 +175,32 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -47,10 +47,13 @@
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a>
|
||||
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. #}
|
||||
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. Sichtbar nur
|
||||
im Bereich System, sonst stünde das Menü dauerhaft aufgeklappt. #}
|
||||
{% if nav|default('') in ['system', 'mail'] %}
|
||||
<ul class="nav-sub">
|
||||
<li><a class="nav-item" href="{{ path('admin_mail') }}"{{ nav|default('') == 'mail' ? ' aria-current="page"' : '' }}>E-Mail-Versand</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
|
||||
|
||||
@@ -268,22 +268,22 @@
|
||||
<div class="card-body">
|
||||
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
|
||||
|
||||
<h3>Exportieren</h3>
|
||||
<form action="{{ path('admin_package_export') }}" method="post">
|
||||
<form class="card-section" action="{{ path('admin_package_export') }}" method="post">
|
||||
<h3>Exportieren</h3>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
|
||||
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
|
||||
<button class="secondary" type="submit">Paket herunterladen</button>
|
||||
<div class="button-row"><button class="secondary" type="submit">Paket herunterladen</button></div>
|
||||
</form>
|
||||
|
||||
<h3>Importieren</h3>
|
||||
<form action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
|
||||
<form class="card-section" action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
|
||||
<h3>Importieren</h3>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="paket-datei">Paket hochladen (.zip)</label>
|
||||
<label class="field-first" for="paket-datei">Paket hochladen (.zip)</label>
|
||||
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
|
||||
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
|
||||
{% if packages is not empty %}
|
||||
<fieldset class="field-group">
|
||||
<fieldset class="field-set">
|
||||
<legend>Oder ein Paket aus var/pakete/</legend>
|
||||
{% for package in packages %}
|
||||
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
|
||||
@@ -296,7 +296,7 @@
|
||||
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
|
||||
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
|
||||
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
|
||||
<button class="primary" type="submit">Paket importieren</button>
|
||||
<div class="button-row"><button class="primary" type="submit">Paket importieren</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
|
||||
{% if type in ['section', 'row', 'column'] %}
|
||||
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}">
|
||||
{% set backgroundVideo = type == 'section' ? props.backgroundVideo|default('') : '' %}
|
||||
{% set backgroundWebm = type == 'section' ? props.backgroundVideoWebm|default('') : '' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}{{ backgroundVideo or backgroundWebm ? ' has-video' : '' }}">
|
||||
{% if block.anchor|default('') %}<span id="{{ block.anchor }}" class="block-anchor" aria-hidden="true"></span>{% endif %}
|
||||
{% if backgroundVideo or backgroundWebm %}{# Dekoration: Die Sektion behält ihr Hintergrundbild, es bleibt stehen, bis das Video läuft. #}
|
||||
<video class="section-video" muted loop playsinline preload="{{ editing|default(false) ? 'metadata' : 'none' }}" aria-hidden="true" tabindex="-1"{{ editing|default(false) ? '' : ' data-video-loop' }}>{% if backgroundWebm %}<source src="{{ backgroundWebm }}" type="video/webm">{% endif %}{% if backgroundVideo %}<source src="{{ backgroundVideo }}" type="{{ backgroundVideo ends with '.webm' ? 'video/webm' : 'video/mp4' }}">{% endif %}</video>{% endif %}
|
||||
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
{% if type == 'section' %}</div>{% endif %}
|
||||
{% if (backgroundVideo or backgroundWebm) and not editing|default(false) %}{% include 'blocks/video-toggle.html.twig' only %}{% endif %}
|
||||
</{{ tag }}>
|
||||
{% elseif type == 'heading' %}
|
||||
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{% if richHeadings|default(false) %}{{ props.text|raw }}{% else %}{{ props.text }}{% endif %}</h{{ props.level }}>
|
||||
@@ -271,6 +276,17 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elseif type == 'video' %}
|
||||
{% set source = props.src|default('') %}
|
||||
{% if source == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Video: unter Inhalt ein Video wählen.</p>{% endif %}
|
||||
{% else %}
|
||||
{% set looping = props.mode|default('player') == 'loop' %}
|
||||
<figure id="{{ block.id }}" class="block-video{{ designClasses }}">
|
||||
<video class="video-media"{% if looping %} muted loop playsinline preload="{{ editing|default(false) ? 'metadata' : 'none' }}"{{ editing|default(false) ? '' : ' data-video-loop' }}{% else %} controls playsinline preload="metadata"{% endif %}{% if props.poster|default('') %} poster="{{ props.poster }}"{% endif %}{% if props.title|default('') %} aria-label="{{ props.title }}"{% elseif looping %} aria-hidden="true"{% else %} aria-label="{{ 'Video'|kant_t }}"{% endif %}>{% if props.srcWebm|default('') %}<source src="{{ props.srcWebm }}" type="video/webm">{% endif %}<source src="{{ source }}" type="{{ source ends with '.webm' ? 'video/webm' : 'video/mp4' }}"></video>
|
||||
{% if looping and not editing|default(false) %}{% include 'blocks/video-toggle.html.twig' only %}{% endif %}
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% elseif type == 'html' %}
|
||||
{# Markup des Betreibers, unveraendert. Geprueft wurde es beim Speichern (CustomCode::checkHtml),
|
||||
setzen darf es nur ein Administrator - deshalb steht hier `raw` und keine Filterung. #}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{# Sichtbar erst durch video.js - ohne Skript läuft auch nichts, das anzuhalten wäre. #}
|
||||
<button class="video-toggle" type="button" hidden aria-label="{{ 'Video anhalten'|kant_t }}" data-pause="{{ 'Video anhalten'|kant_t }}" data-play="{{ 'Video abspielen'|kant_t }}"></button>
|
||||
@@ -67,6 +67,7 @@
|
||||
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if needsFormScript|default(false) and not editing|default(false) %}<script src="/assets/form-steps.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsVideo|default(false) and not editing|default(false) %}<script type="module" src="/assets/video.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
|
||||
@@ -8,16 +8,23 @@ use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\EventSubscriber\RemoteUpdateFinishSubscriber;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Funktionspruefung;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Tasks\ZentraleTask;
|
||||
use App\Ops\Updater;
|
||||
use App\Ops\Zentrale;
|
||||
use App\Security\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
@@ -337,6 +344,111 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
|
||||
}
|
||||
|
||||
public function testARemoteOrderUpdatesTheSiteAndTheNextRequestFinishesIt(): void
|
||||
{
|
||||
// ADR 123: Die Zentrale merkt sich „Update auf 1.1.0“, die Website holt es mit ihrer
|
||||
// Meldung ab, tauscht die Dateien, und die nächste Anfrage führt die Migrationen aus.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$server = $this->zentraleServer(['id' => 7, 'aktion' => 'update', 'fassung' => '1.1.0']);
|
||||
$zentrale = $this->zentrale($server['senden']);
|
||||
$task = new ZentraleTask($zentrale, $this->updater(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db), 'https://kunde.example.de', $this->root, function (string $adresse): void {
|
||||
self::assertSame('https://kunde.example.de/', $adresse);
|
||||
$this->naechsteAnfrage();
|
||||
});
|
||||
|
||||
$meldung = $task->run();
|
||||
|
||||
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), $meldung);
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die nächste Anfrage hat abgeschlossen und die Website freigegeben.');
|
||||
self::assertNull($this->updater()->pending());
|
||||
$meldungen = $server['meldungen']();
|
||||
self::assertArrayHasKey('schluessel', $meldungen[0], 'Beim ersten Kontakt geht der Schlüssel mit.');
|
||||
self::assertArrayNotHasKey('schluessel', $meldungen[1], 'Danach nur noch die Unterschrift.');
|
||||
self::assertSame('1.0.0', $meldungen[0]['fassung']);
|
||||
$letzte = end($meldungen);
|
||||
self::assertSame('1.1.0', $letzte['fassung']);
|
||||
self::assertSame(['id' => 7, 'ok' => true], array_intersect_key($letzte['auftrag'] ?? [], ['id' => 1, 'ok' => 1]), 'Das Ergebnis geht an die Zentrale zurück.');
|
||||
self::assertNull($zentrale->offenerAuftrag(), 'Nach der Quittung ist der Auftrag erledigt.');
|
||||
}
|
||||
|
||||
public function testAnAnswerWithoutAValidSignatureChangesNothing(): void
|
||||
{
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$zentrale = $this->zentrale(static fn (): array => [
|
||||
'status' => 200,
|
||||
'body' => '{"ok":true,"auftrag":{"id":1,"aktion":"update","fassung":"1.1.0"}}',
|
||||
'signatur' => str_repeat('0', 64),
|
||||
]);
|
||||
|
||||
try {
|
||||
$zentrale->melden();
|
||||
self::fail('Eine Antwort ohne gültige Unterschrift wurde angenommen.');
|
||||
} catch (\RuntimeException $fehler) {
|
||||
self::assertStringContainsString('Unterschrift', $fehler->getMessage());
|
||||
}
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
}
|
||||
|
||||
public function testALocalInstallationDoesNotReport(): void
|
||||
{
|
||||
$gesendet = false;
|
||||
$senden = static function () use (&$gesendet): array {
|
||||
$gesendet = true;
|
||||
|
||||
return ['status' => 500, 'body' => '', 'signatur' => ''];
|
||||
};
|
||||
foreach (['http://127.0.0.1:8106', 'http://localhost', 'https://kunde.test'] as $adresse) {
|
||||
$zentrale = new Zentrale($this->root, new Updater($this->root, 'sqlite:///x', new CommandRunner(), $this->db), $this->updater(), 'https://sites.example.invalid', $adresse, 'sqlite:///x', $senden);
|
||||
self::assertFalse($zentrale->aktiv(), $adresse);
|
||||
}
|
||||
self::assertFalse($this->zentrale(null, 'aus')->aktiv(), 'ZENTRALE_URL=aus schaltet ab.');
|
||||
self::assertFalse($gesendet);
|
||||
}
|
||||
|
||||
/** Die Anfrage nach dem Tausch: startet mit dem neuen Code und schließt ab. */
|
||||
private function naechsteAnfrage(): void
|
||||
{
|
||||
$pruefung = $this->createStub(Funktionspruefung::class);
|
||||
$pruefung->method('__invoke')->willReturn(null);
|
||||
$runner = new CommandRunner();
|
||||
$subscriber = new RemoteUpdateFinishSubscriber($this->updater(), $this->installation(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', $runner, $this->db), $pruefung, $this->root);
|
||||
$subscriber->onRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), Request::create('/'), HttpKernelInterface::MAIN_REQUEST));
|
||||
}
|
||||
|
||||
private function zentrale(?\Closure $senden, string $url = 'https://sites.example.invalid'): Zentrale
|
||||
{
|
||||
$url_db = 'sqlite:///'.$this->root.'/var/cms.db';
|
||||
|
||||
return new Zentrale($this->root, new Updater($this->root, $url_db, new CommandRunner(), $this->db), $this->updater(), $url, 'https://kunde.example.de', $url_db, $senden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zentrale im Speicher: lernt den Schlüssel beim ersten Kontakt, prüft danach jede
|
||||
* Unterschrift, gibt den Auftrag einmal heraus und unterschreibt jede Antwort.
|
||||
*
|
||||
* @return array{senden: \Closure, meldungen: \Closure}
|
||||
*/
|
||||
private function zentraleServer(?array $auftrag): array
|
||||
{
|
||||
$schluessel = null;
|
||||
$meldungen = [];
|
||||
$senden = function (string $url, string $body, array $kopf) use (&$schluessel, &$meldungen, &$auftrag): array {
|
||||
$daten = json_decode($body, true);
|
||||
$meldungen[] = $daten;
|
||||
$schluessel ??= $daten['schluessel'] ?? null;
|
||||
self::assertIsString($schluessel);
|
||||
self::assertTrue(hash_equals(hash_hmac('sha256', $body, $schluessel), $kopf['X-KantSites-Signatur']), 'Jede Meldung ist unterschrieben.');
|
||||
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag]);
|
||||
$auftrag = null;
|
||||
|
||||
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
||||
};
|
||||
|
||||
return ['senden' => $senden, 'meldungen' => static function () use (&$meldungen): array {
|
||||
return $meldungen;
|
||||
}];
|
||||
}
|
||||
|
||||
private function installation(): Installation
|
||||
{
|
||||
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
|
||||
|
||||
@@ -24,6 +24,7 @@ use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -1399,6 +1400,95 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||||
}
|
||||
|
||||
public function testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground(): void
|
||||
{
|
||||
// Umut: „Baust du video embedding gleich ein" - der Hero von kant-it.de soll ein Video
|
||||
// bekommen, ohne dass dafür Dateien von Hand auf den Server müssen.
|
||||
$videos = new VideoLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-video-');
|
||||
try {
|
||||
file_put_contents($file, '<html><script>alert(1)</script></html>');
|
||||
try {
|
||||
$videos->upload(new UploadedFile($file, 'film.mp4', test: true));
|
||||
self::fail('Eine Datei, die kein Video ist, darf nicht als Video durchgehen.');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
file_put_contents($file, "\0\0\0\x14ftypqt \0\0\0\0qt ".str_repeat("\0", 64));
|
||||
try {
|
||||
$videos->upload(new UploadedFile($file, 'film.mov', test: true));
|
||||
self::fail('MOV spielen nicht alle Browser ab und wird deshalb abgewiesen.');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM video'));
|
||||
|
||||
file_put_contents($file, "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat('x', 2000));
|
||||
$item = $videos->upload(new UploadedFile($file, 'Hero Loop.mp4', test: true));
|
||||
file_put_contents($file, "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\x82\x84webm".str_repeat('y', 2000));
|
||||
$webm = $videos->upload(new UploadedFile($file, 'hero.bin', test: true));
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
self::assertSame('Hero Loop', $item['title']);
|
||||
self::assertSame('/media/'.$item['id'].'.mp4', $item['src']);
|
||||
self::assertSame('/media/'.$webm['id'].'.webm', $webm['src']);
|
||||
|
||||
$antwort = $this->request($item['src']);
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertSame('video/mp4', $antwort->headers->get('Content-Type'));
|
||||
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
|
||||
self::assertSame("default-src 'none'", $antwort->headers->get('Content-Security-Policy'));
|
||||
self::assertSame('bytes', $antwort->headers->get('Accept-Ranges'));
|
||||
// Dieselbe Kennung unter der falschen Endung gibt es nicht.
|
||||
self::assertSame(404, $this->request('/media/'.$item['id'].'.webm')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.mp4')->getStatusCode());
|
||||
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
|
||||
$document['blocks'][0]['props'] = ['backgroundVideo' => $item['src'], 'backgroundVideoWebm' => $webm['src']];
|
||||
$document['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => 'Unser Betrieb', 'poster' => '/media/'.str_repeat('b', 32).'/1600']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$seite = $this->request('/');
|
||||
$html = $seite->getContent();
|
||||
self::assertStringContainsString('has-video', $html);
|
||||
self::assertMatchesRegularExpression('#<video class="section-video"[^>]* muted loop playsinline preload="none"[^>]*data-video-loop#', $html);
|
||||
// Das WebM steht zuerst: der Browser nimmt die erste Quelle, die er abspielen kann.
|
||||
self::assertLessThan(strpos($html, 'src="'.$item['src'].'"'), strpos($html, 'src="'.$webm['src'].'"'));
|
||||
self::assertStringContainsString('class="video-toggle"', $html);
|
||||
self::assertStringContainsString('aria-label="Unser Betrieb"', $html);
|
||||
self::assertMatchesRegularExpression('#<video class="video-media" controls playsinline preload="metadata" poster=#', $html);
|
||||
self::assertStringContainsString('<script type="module" src="/assets/video.js?v=', $html);
|
||||
self::assertStringContainsString("script-src 'self'", (string) $seite->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Ein Spieler allein braucht kein Skript: die Seite bleibt bei script-src 'none'.
|
||||
$spieler = DemoContent::starter('Startseite');
|
||||
$spieler['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
|
||||
$spieler['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => '']];
|
||||
$version = $this->pages->findForEditor('page-1')['version'];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $spieler, $version), $version + 1);
|
||||
$ohne = $this->request('/');
|
||||
self::assertStringNotContainsString('video.js', $ohne->getContent());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Fremde Adressen, zu altes Schema und unbekannte Betriebsarten weist das Dokument ab.
|
||||
$pruefer = new DocumentValidator();
|
||||
foreach ([
|
||||
['video', ['src' => 'https://example.com/film.mp4'], DocumentValidator::SCHEMA_VERSION],
|
||||
['video', ['src' => '/media/../secret.mp4'], DocumentValidator::SCHEMA_VERSION],
|
||||
['video', ['src' => $item['src'], 'mode' => 'autoplay'], DocumentValidator::SCHEMA_VERSION],
|
||||
['video', ['src' => $item['src']], 27],
|
||||
] as [$type, $props, $schema]) {
|
||||
$falsch = DemoContent::starter('Startseite');
|
||||
$falsch['schemaVersion'] = $schema;
|
||||
$falsch['blocks'][] = ['id' => 'film', 'type' => $type, 'props' => $props];
|
||||
try {
|
||||
$pruefer->validate($falsch);
|
||||
self::fail('Abgewiesen werden muss: '.json_encode($props));
|
||||
} catch (InvalidDocument) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
|
||||
{
|
||||
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
|
||||
@@ -4360,6 +4450,53 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
|
||||
}
|
||||
|
||||
public function testBusinessTypeLocationAndLogoReachTheStructuredData(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$basis = ['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Luximmo UG', 'street' => 'Hauptstraße 169', 'city' => 'Gaggenau', 'localBusiness' => '1'];
|
||||
$daten = function (): array {
|
||||
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
|
||||
|
||||
return json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
|
||||
};
|
||||
|
||||
// Ohne Angaben bleibt alles, wie es war: bestehende Websites ändern sich nicht.
|
||||
$this->site->save($basis);
|
||||
$vorher = $daten();
|
||||
self::assertSame('LocalBusiness', $vorher['@type']);
|
||||
self::assertArrayNotHasKey('geo', $vorher);
|
||||
self::assertArrayNotHasKey('logo', $vorher);
|
||||
|
||||
$mediaId = bin2hex(random_bytes(16));
|
||||
$this->db->insert('media', ['id' => $mediaId, 'title' => 'Logo', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$mediaId.'/480', 'width' => 480, 'height' => 420], ['src' => '/media/'.$mediaId.'/640', 'width' => 640, 'height' => 561]], JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => '2026-11-09 00:00:00']);
|
||||
$eingabe = [...$basis, 'businessType' => 'RealEstateAgent', 'latitude' => '48,79381', 'longitude' => '8.33237', 'organisationLogoMediaId' => $mediaId];
|
||||
$this->site->save($eingabe);
|
||||
$nachher = $daten();
|
||||
self::assertSame('RealEstateAgent', $nachher['@type']);
|
||||
self::assertSame(['@type' => 'GeoCoordinates', 'latitude' => 48.79381, 'longitude' => 8.33237], $nachher['geo']);
|
||||
self::assertStringEndsWith('/media/'.$mediaId.'/640', $nachher['logo']);
|
||||
|
||||
// Erneutes Speichern der geladenen Werte verliert nichts.
|
||||
$this->site->save([...$this->site->load(), 'sameAs' => '']);
|
||||
self::assertSame($nachher, $daten());
|
||||
|
||||
// Ohne „lokal“ gibt es weder Unterart noch Standort; das Logo gilt für jede Organisation.
|
||||
$this->site->save([...$eingabe, 'localBusiness' => '']);
|
||||
$organisation = $daten();
|
||||
self::assertSame('Organization', $organisation['@type']);
|
||||
self::assertArrayNotHasKey('geo', $organisation);
|
||||
self::assertArrayHasKey('logo', $organisation);
|
||||
|
||||
foreach ([['businessType' => 'Geheimdienst'], ['latitude' => '91', 'longitude' => '8'], ['latitude' => 'abc', 'longitude' => '8'], ['latitude' => '48.1'], ['organisationLogoMediaId' => str_repeat('a', 32)]] as $falsch) {
|
||||
try {
|
||||
$this->site->save([...$basis, ...$falsch]);
|
||||
self::fail('Ungültige Angabe angenommen: '.json_encode($falsch));
|
||||
} catch (InvalidPage) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
|
||||
{
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Plugins mit eigenem PHP-Code (ADR 127): laden, Hooks, Aufgaben, Seiten, Fehler eingrenzen. */
|
||||
final class PluginServerTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private Connection $db;
|
||||
private PluginRegistry $plugins;
|
||||
private PluginRuntime $runtime;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-plugin-server-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root.'/plugins', 0o775, true);
|
||||
file_put_contents($this->root.'/VERSION', "1.39.0\n");
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/test.db');
|
||||
TestSchema::build($this->db);
|
||||
$this->fresh();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->runtime, $this->plugins);
|
||||
$this->db->close();
|
||||
$remove = static function (string $path) use (&$remove): void {
|
||||
foreach (glob($path.'/*') ?: [] as $entry) {
|
||||
is_dir($entry) ? $remove($entry) : @unlink($entry);
|
||||
}
|
||||
@rmdir($path);
|
||||
};
|
||||
$remove($this->root);
|
||||
}
|
||||
|
||||
public function testAPluginRegistersHooksTasksPagesAndItsOwnTable(): void
|
||||
{
|
||||
$this->plugin('demo', <<<'PHP'
|
||||
$api->onInstall(fn () => $api->db()->executeStatement('CREATE TABLE IF NOT EXISTS '.$api->table('zeile').' (wert TEXT)'));
|
||||
$api->addFilter('liste', fn (array $w) => [...$w, 'spaet'], 20);
|
||||
$api->addFilter('liste', fn (array $w) => [...$w, 'frueh'], 5);
|
||||
$api->addTask('takt', 'Takt', 'Erklaerung', 600, fn () => 'gelaufen');
|
||||
$api->addPage('/objekte/{slug}', fn (array $p) => 'gibt-es' === $p['slug'] ? ['title' => 'T', 'description' => 'D', 'blocks' => [], 'status' => 410, 'robots' => 'noindex'] : null);
|
||||
$api->addData('zahl', fn (array $a) => $a['x'] * 2);
|
||||
PHP);
|
||||
$this->runtime->activate('demo');
|
||||
|
||||
$this->db->executeStatement('INSERT INTO plugin_demo_zeile (wert) VALUES (?)', ['ja']);
|
||||
self::assertSame(['frueh', 'spaet'], $this->runtime->applyFilters('liste', []));
|
||||
self::assertSame(['plugin-demo-takt'], array_map(static fn ($t) => $t->name(), $this->runtime->tasks()));
|
||||
self::assertSame('gelaufen', $this->runtime->tasks()[0]->run());
|
||||
self::assertSame(42, $this->runtime->data('demo', 'zahl', ['x' => 21]));
|
||||
|
||||
$seite = $this->runtime->page('/objekte/gibt-es');
|
||||
self::assertSame(410, $seite['status']);
|
||||
self::assertSame('noindex', $seite['robots']);
|
||||
self::assertSame('objekte/gibt-es', $seite['path']);
|
||||
self::assertNull($this->runtime->page('/objekte/gibt-es-nicht'));
|
||||
self::assertNull($this->runtime->page('/objekte/a/b'));
|
||||
|
||||
// Der Aufgabenplan kennt die Aufgabe, und nach dem Ausschalten nicht mehr.
|
||||
$plan = new Scheduler($this->db, [], $this->root, $this->runtime);
|
||||
$plan->pluginsChanged();
|
||||
self::assertSame('plugin-demo-takt', $this->db->fetchOne('SELECT name FROM scheduled_task'));
|
||||
self::assertSame('ok', $plan->runNow('plugin-demo-takt')['status']);
|
||||
$this->runtime->deactivate('demo');
|
||||
$plan->pluginsChanged();
|
||||
self::assertFalse($this->db->fetchOne('SELECT name FROM scheduled_task'));
|
||||
self::assertSame([], $this->runtime->applyFilters('liste', []));
|
||||
|
||||
// Eigene Tabellen überleben das Ausschalten.
|
||||
self::assertSame('ja', $this->db->fetchOne('SELECT wert FROM plugin_demo_zeile'));
|
||||
}
|
||||
|
||||
public function testAFaultyPluginStaysContained(): void
|
||||
{
|
||||
$this->plugin('kaputt', 'throw new \RuntimeException("Beim Laden gescheitert");');
|
||||
$this->plugin('wackel', <<<'PHP'
|
||||
$api->addFilter('wert', function ($w) { throw new \RuntimeException('Filterfehler'); });
|
||||
$api->addFilter('wert', fn ($w) => $w.'+gut', 20);
|
||||
$api->addPage('/wackel', function () { throw new \RuntimeException('Seitenfehler'); });
|
||||
PHP);
|
||||
try {
|
||||
$this->runtime->activate('kaputt');
|
||||
self::fail('Ein Plugin, das nicht lädt, darf nicht eingeschaltet bleiben.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('Beim Laden gescheitert', $e->getMessage());
|
||||
}
|
||||
self::assertFalse($this->plugins->all()['kaputt']['active']);
|
||||
|
||||
$this->runtime->activate('wackel');
|
||||
self::assertSame('x+gut', $this->runtime->applyFilters('wert', 'x'));
|
||||
self::assertNull($this->runtime->page('/wackel'));
|
||||
self::assertSame('Seitenfehler', $this->runtime->errors()['wackel']);
|
||||
}
|
||||
|
||||
public function testAFailedInstallLeavesThePluginOff(): void
|
||||
{
|
||||
$this->plugin('demo', '$api->onInstall(function () { throw new \RuntimeException("Keine Tabelle"); });');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
try {
|
||||
$this->runtime->activate('demo');
|
||||
} finally {
|
||||
self::assertFalse($this->plugins->all()['demo']['active']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testANewVersionRunsTheInstallAgainOnce(): void
|
||||
{
|
||||
$code = '$api->onInstall(fn () => $api->db()->executeStatement("INSERT INTO media (id, title, alt, variants, archived, version, created_at) VALUES (lower(hex(randomblob(16))), \'t\', \'\', \'[]\', 0, 1, \'2026-01-01 00:00:00\')"));';
|
||||
$this->plugin('demo', $code);
|
||||
$this->runtime->activate('demo');
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
|
||||
$this->plugin('demo', $code, '1.1.0');
|
||||
$this->fresh();
|
||||
$this->runtime->tasks();
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
$this->fresh();
|
||||
$this->runtime->tasks();
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
}
|
||||
|
||||
public function testAPluginCannotTakeCoreAddressesOrOtherFolders(): void
|
||||
{
|
||||
$this->plugin('demo', '$api->addPage("/admin/{x}", fn () => null);');
|
||||
try {
|
||||
$this->runtime->activate('demo');
|
||||
self::fail('Die Verwaltung gehört dem Kern.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('gehört dem Kern', $e->getMessage());
|
||||
}
|
||||
file_put_contents($this->root.'/plugins/demo/plugin.json', json_encode(['name' => 'demo', 'title' => 'D', 'version' => '1.0.0', 'main' => '../fremd.php'], JSON_THROW_ON_ERROR));
|
||||
$this->fresh();
|
||||
self::assertNotNull($this->plugins->all()['demo']['error']);
|
||||
}
|
||||
|
||||
private function fresh(): void
|
||||
{
|
||||
$this->plugins = new PluginRegistry($this->db, $this->root);
|
||||
$this->runtime = new PluginRuntime($this->plugins, $this->db, new MediaLibrary($this->db, $this->root.'/media'));
|
||||
}
|
||||
|
||||
private function plugin(string $name, string $body, string $version = '1.0.0'): void
|
||||
{
|
||||
@mkdir($this->root.'/plugins/'.$name);
|
||||
file_put_contents($this->root.'/plugins/'.$name.'/plugin.json', json_encode(['name' => $name, 'title' => $name, 'version' => $version, 'main' => 'plugin.php'], JSON_THROW_ON_ERROR));
|
||||
file_put_contents($this->root.'/plugins/'.$name.'/plugin.php', "<?php\nreturn static function (\\App\\Plugin\\PluginApi \$api): void {\n".$body."\n};\n");
|
||||
clearstatcache();
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
opcache_invalidate($this->root.'/plugins/'.$name.'/plugin.php', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,6 +412,21 @@ final class SystemScreenTest extends WebTestCase
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
|
||||
|
||||
// Umut: „Logs leeren Funktion brauch ich“ - nur mit Token, danach ist die Liste leer.
|
||||
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => 'erfunden']);
|
||||
self::assertFileExists($this->path.'-mail.log', 'Ohne Token bleibt das Protokoll.');
|
||||
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
|
||||
self::assertFileDoesNotExist($this->path.'-mail.log');
|
||||
$client->followRedirect();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Das Protokoll ist leer.', $html);
|
||||
self::assertStringContainsString('Noch nichts versendet', $html);
|
||||
self::assertStringNotContainsString('Protokoll leeren', $html, 'Ohne Einträge gibt es nichts zu leeren.');
|
||||
|
||||
// Der Unterpunkt steht nur im Bereich System offen, nicht dauerhaft.
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertStringNotContainsString('href="/admin/system/e-mail"', (string) $client->getResponse()->getContent());
|
||||
|
||||
// Nur für Administratoren, und nur mit Token.
|
||||
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => 'erfunden', 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
|
||||
self::assertEmailCount(0);
|
||||
@@ -420,6 +435,8 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
$redaktion->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
$redaktion->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
|
||||
@@ -70,7 +70,7 @@ final class TaskScreenTest extends WebTestCase
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
|
||||
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) {
|
||||
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup', 'zentrale'] as $task) {
|
||||
self::assertStringContainsString($task, $html);
|
||||
}
|
||||
// Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie
|
||||
@@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
|
||||
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
|
||||
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
|
||||
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
|
||||
|
||||
@@ -24,6 +24,7 @@ use DoctrineMigrations\Version20260916000100;
|
||||
use DoctrineMigrations\Version20260917000100;
|
||||
use DoctrineMigrations\Version20261009000100;
|
||||
use DoctrineMigrations\Version20261012000100;
|
||||
use DoctrineMigrations\Version20261104000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,7 @@ final class TestSchema
|
||||
Version20260917000100::class,
|
||||
Version20261009000100::class,
|
||||
Version20261012000100::class,
|
||||
Version20261104000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Für den Fall, dass die Subdomain auf diesen Ordner zeigt statt auf public/:
|
||||
# app/, var/ und config.php bleiben verschlossen, alles andere geht nach public/.
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^(app|var)(/|$) - [F,L]
|
||||
RewriteRule ^(config\.php|config\.example\.php|README\.md)$ - [F,L]
|
||||
RewriteCond %{REQUEST_URI} !^/public/
|
||||
RewriteRule ^(.*)$ public/$1 [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_rewrite.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user