Compare commits

...
5 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 02e4412771 Release 1.36.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:44:26 +02:00
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00
kantarciandClaude Opus 5.5 32cb77b72e Serve Lora and Manrope for the EBI Immobilien draft
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:39 +02:00
kantarciandClaude Opus 5.5 233bab1e43 Add the EBI Immobilien redesign draft
Rebuild of ebi-immobilien.de (Karlsruhe) in its own installation on
ports 8106/8107: home page, search request and valuation as step forms,
contact, legal pages and redirects for the old WordPress addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:18 +02:00
kantarciandClaude Opus 5.5 7fb4d2bfbd Note the 1.35.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:50:01 +02:00
59 changed files with 3699 additions and 31 deletions

No files matched your search

+27 -1
View File
@@ -208,6 +208,32 @@
"$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": "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
}
]
}
}
+3
View File
@@ -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
+5
View File
@@ -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
+6
View File
@@ -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,10 @@ 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.
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`.
+1 -1
View File
@@ -1 +1 @@
1.35.1
1.36.0
+18
View File
@@ -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:
@@ -150,6 +153,21 @@ services:
arguments:
$tasks: !tagged_iterator kant.task
$projectDir: '%kernel.project_dir%'
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%'
+19 -1
View File
@@ -1,6 +1,24 @@
# Architekturentscheidungen
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen.
## 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)
+28 -2
View File
@@ -1,6 +1,30 @@
# Projektstatus
## Nach 1.35.0: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
## 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.
**Nächster Schritt:** Release 1.36.0 bauen und veröffentlichen, `zentrale/` nach sites.kant-it.de hochladen (Docroot `public/`, HTTPS, `config.php`), erstes Konto anlegen, kant-it.de auf 1.36.0 heben und die erste Meldung sowie ein Fern-Update mit 1.36.1 prüfen.
## 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.
@@ -8,7 +32,9 @@
**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.
**Offen:** nicht veröffentlicht (kein Tag/Release).
**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)
+4
View File
@@ -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:
+5
View File
@@ -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
+8
View File
@@ -38,3 +38,11 @@
@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}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.
+93
View File
@@ -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.
+94
View File
@@ -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";
+1 -1
View File
@@ -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;
+31
View File
@@ -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"
+978
View File
@@ -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";
+109
View File
@@ -0,0 +1,109 @@
<?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');
echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
exit($fehler > 0 ? 1 : 0);
+3 -23
View File
@@ -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);
}
}
}
+15
View File
@@ -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.
*
+38
View File
@@ -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;
}
}
+173
View File
@@ -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);
}
}
+287
View File
@@ -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];
}
}
+112
View File
@@ -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'));
+2 -2
View File
@@ -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
+15
View File
@@ -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>
+78
View File
@@ -0,0 +1,78 @@
# KantSites Zentrale (sites.kant-it.de)
Die Übersicht aller KantSites-Installationen: welche Domain, welche Fassung, wann zuletzt
gemeldet, und ein Knopf für Updates – einzeln oder für alle. Entscheidung und Protokoll:
ADR 123 in `docs/ARCHITECTURE.md`.
Reines PHP (ab 8.1) mit SQLite, ohne Composer und ohne Framework. Nötig sind die Erweiterungen
`pdo_sqlite` und `curl` (oder `allow_url_fopen`) – beides ist auf üblichem Webhosting da.
## Hochladen
1. Den Ordner `zentrale/` komplett per FTP hochladen, zum Beispiel nach `/sites.kant-it.de/`.
2. Die Subdomain `sites.kant-it.de` auf den Unterordner **`public/`** zeigen lassen.
Geht das beim Hoster nicht, auf den Ordner selbst – die `.htaccess` dort leitet nach `public/`
und sperrt `app/`, `var/` und `config.php`.
3. HTTPS (Let's Encrypt) für die Subdomain einschalten. Die Installationen melden nur über HTTPS.
4. `config.example.php` als `config.php` kopieren und einen zufälligen `einrichtungscode`
(mindestens 16 Zeichen) eintragen.
5. `var/` muss für PHP beschreibbar sein (dort liegen `zentrale.db` und der Fassungs-Cache).
6. `https://sites.kant-it.de` aufrufen, mit dem Einrichtungscode das erste Konto anlegen und
anmelden. Danach wird der Code nicht mehr gebraucht.
Prüfen: `https://sites.kant-it.de/api/melden` im Browser antwortet mit
`{"ok":false,"fehler":"Nur POST."}` – dann ist die Schnittstelle erreichbar.
## Wie die Installationen sich melden
Ab KantSites **1.36.0** meldet sich jede Installation alle 15 Minuten über die Aufgabe
„Bei der Zentrale melden“ (Verwaltung → Aufgaben). Sie läuft wie alle Aufgaben beim Besuch,
in der Verwaltung oder über den URL-Cronjob. Ältere Installationen erscheinen erst nach dem Update
auf 1.36.0 – das letzte Mal von Hand über „System“.
Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `127.0.0.1`,
`*.test` oder `*.localhost` zeigt. Abschalten pro Installation: `ZENTRALE_URL=aus` in `.env`.
Übertragen werden: Adresse, Fassung, PHP-Version, Datenbankart (sqlite/mysql), Git oder
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
## Update aus der Ferne
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
2. Die Installation holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an,
lädt das Release mit Prüfsumme, tauscht die Dateien und ruft sich selbst einmal auf. Diese
Anfrage führt mit dem neuen Code die Migrationen aus, prüft die Startseite und gibt die
Website frei – oder schaltet auf die alte Fassung zurück.
3. Das Ergebnis erscheint mit der nächsten Meldung in der Zentrale.
Einschränkungen:
- Git-Arbeitskopien werden nicht aus der Ferne aktualisiert.
- Erreicht sich eine Website nicht selbst (manche Hoster sperren das), schließt der nächste
Besucher oder Cron-Aufruf das Update ab. So lange zeigt sie die Wartungsseite.
- Ein abgeholter Auftrag ohne Ergebnis nach 2 Stunden erscheint als „keine Rückmeldung“.
## Fremde Installationen erkennen
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
**Unbekannt** in der Liste. Meldet sich eine bekannte Kennung unter einer anderen Domain, zeigt die
Liste „vorher …“. Wer KantSites verändert und die Meldung ausbaut, taucht nicht auf – einen
vollständigen Schutz gibt es ohne Lizenzschlüssel nicht.
## Lokal ausprobieren
```bash
php -S 127.0.0.1:8115 -t zentrale/public zentrale/public/index.php
```
Protokollprüfung zwischen CMS und Zentrale (ohne Netz):
```bash
php scripts/zentrale-check.php
```
## Sicherung
Alles Wichtige steht in `var/zentrale.db`. Geht sie verloren, melden sich die Installationen bei
ihrer nächsten Meldung neu an; Kundenzuordnungen und Auftragsverlauf sind dann weg.
+1
View File
@@ -0,0 +1 @@
Require all denied
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Vorlagen als PHP-Dateien. Alles, was ausgegeben wird, geht durch e().
*/
final class Ansicht
{
public function __construct(private string $csrf)
{
}
/** @param array<string, mixed> $daten */
public function vorlage(string $datei, array $daten): string
{
$v = $this;
extract($daten, EXTR_SKIP);
ob_start();
require $datei;
return (string) ob_get_clean();
}
public function e(mixed $wert): string
{
return htmlspecialchars((string) $wert, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public function csrf(): string
{
return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">';
}
/** Ein Zeitpunkt in deutscher Ortszeit. */
public function zeit(?string $iso): string
{
if (null === $iso || '' === $iso || false === strtotime($iso)) {
return '–';
}
return (new \DateTimeImmutable($iso))->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('d.m.Y, H:i');
}
/** „vor 5 Minuten“ - für die letzte Meldung. */
public function vor(int $sekunden): string
{
return match (true) {
$sekunden < 90 => 'gerade eben',
$sekunden < 5400 => sprintf('vor %d Minuten', (int) round($sekunden / 60)),
$sekunden < 129600 => sprintf('vor %d Stunden', (int) round($sekunden / 3600)),
default => sprintf('vor %d Tagen', (int) round($sekunden / 86400)),
};
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die Weiche: Adresse und Methode zu einer Aktion. Kein Framework - die Plattform hat sieben
* Seiten und eine Schnittstelle, und sie soll sich per FTP auf jedes Hosting mit PHP legen lassen.
*/
final class Anwendung
{
private \PDO $db;
/** @param array<string, mixed> $config */
public function __construct(private array $config, private string $wurzel)
{
}
public function bearbeiten(): void
{
$pfad = '/'.trim((string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH), '/');
$methode = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
$this->kopfzeilen();
try {
$this->db = Datenbank::oeffnen($this->wurzel.'/var/zentrale.db');
} catch (\Throwable $fehler) {
http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
echo "Die Datenbank lässt sich nicht öffnen. Ist var/ beschreibbar?\n";
error_log('Zentrale: '.$fehler->getMessage());
return;
}
if ('/api/melden' === $pfad) {
$this->api($methode);
return;
}
if ('/robots.txt' === $pfad) {
header('Content-Type: text/plain; charset=UTF-8');
echo "User-agent: *\nDisallow: /\n";
return;
}
$this->sitzung();
$konten = new Konten($this->db);
if (!$konten->gibtEs()) {
$this->einrichten($konten, $methode);
return;
}
if ('/anmelden' === $pfad) {
$this->anmelden($konten, $methode);
return;
}
if (!isset($_SESSION['konto'])) {
$this->weiter('/anmelden');
return;
}
if ('POST' === $methode && !$this->csrfGueltig()) {
$this->hinweis('Die Sitzung ist abgelaufen. Bitte noch einmal.', 'fehler');
$this->weiter('/' === $pfad ? '/' : $pfad);
return;
}
$fassungen = new Fassungen((string) ($this->config['releases_api'] ?? ''), $this->wurzel.'/var/neueste.json');
$bestand = new Bestand($this->db);
match (true) {
'/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen),
'/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen),
'/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen),
'/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen),
'/auftrag/zurueckziehen' === $pfad && 'POST' === $methode => $this->zurueckziehen($bestand),
'/notiz' === $pfad && 'POST' === $methode => $this->notiz($bestand),
'/entfernen' === $pfad && 'POST' === $methode => $this->entfernen($bestand),
'/fassung' === $pfad && 'POST' === $methode => $this->fassungNeu($fassungen),
'/abmelden' === $pfad && 'POST' === $methode => $this->abmelden(),
default => $this->nichtGefunden(),
};
}
private function api(string $methode): void
{
header('Content-Type: application/json; charset=UTF-8');
if ('POST' !== $methode) {
http_response_code(405);
header('Allow: POST');
echo '{"ok":false,"fehler":"Nur POST."}';
return;
}
$antwort = (new Api($this->db))->melden(
(string) file_get_contents('php://input', false, null, 0, 16385),
(string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''),
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
);
http_response_code($antwort['status']);
if (null !== $antwort['signatur']) {
header('X-KantSites-Signatur: '.$antwort['signatur']);
}
echo $antwort['body'];
}
private function uebersicht(Bestand $bestand, Fassungen $fassungen): void
{
$neueste = $fassungen->neueste();
$alle = $bestand->alle($neueste['version'] ?? null);
$this->zeigen('uebersicht', [
'titel' => 'Installationen',
'neueste' => $neueste,
'installationen' => $alle,
'zahlen' => [
'alle' => count($alle),
'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])),
'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])),
'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])),
'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])),
],
// Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen.
'aktualisierbar' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))),
]);
}
private function detail(Bestand $bestand, Fassungen $fassungen): void
{
$neueste = $fassungen->neueste();
$eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null);
if (null === $eine) {
$this->nichtGefunden();
return;
}
$this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste]);
}
private function auftrag(Bestand $bestand, Fassungen $fassungen): void
{
$id = (string) ($_POST['id'] ?? '');
$neueste = $fassungen->neueste()['version'] ?? null;
$grund = null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : $bestand->beauftragen($id, $neueste);
$this->hinweis(null === $grund ? sprintf('Update auf %s beauftragt. Die Website holt es mit ihrer nächsten Meldung ab (meist innerhalb von 15 Minuten).', $neueste) : $grund, null === $grund ? 'ok' : 'fehler');
$this->weiter('detail' === ($_POST['zurueck'] ?? '') ? '/installation?id='.rawurlencode($id) : '/');
}
private function alleAuftraege(Bestand $bestand, Fassungen $fassungen): void
{
$neueste = $fassungen->neueste()['version'] ?? null;
if ('ja' !== ($_POST['bestaetigt'] ?? '') || null === $neueste) {
$this->hinweis(null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : 'Bitte zuerst bestätigen, dass alle veralteten Installationen aktualisiert werden sollen.', 'fehler');
$this->weiter('/');
return;
}
$anzahl = 0;
foreach ($bestand->alle($neueste) as $installation) {
if ($installation['kann_update'] && !$installation['still'] && null === $bestand->beauftragen((string) $installation['id'], $neueste)) {
++$anzahl;
}
}
$this->hinweis(sprintf('%d Update(s) auf %s beauftragt.', $anzahl, $neueste), 'ok');
$this->weiter('/');
}
private function zurueckziehen(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
$this->hinweis($bestand->zurueckziehen($id) ? 'Auftrag zurückgezogen.' : 'Der Auftrag war schon abgeholt und lässt sich nicht mehr zurückziehen.', 'ok');
$this->weiter('/installation?id='.rawurlencode($id));
}
private function notiz(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
$bestand->notiz($id, (string) ($_POST['notiz'] ?? ''));
$this->hinweis('Gespeichert.', 'ok');
$this->weiter('/installation?id='.rawurlencode($id));
}
private function entfernen(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
if ('ja' !== ($_POST['bestaetigt'] ?? '')) {
$this->hinweis('Bitte bestätigen, dass der Eintrag entfernt werden soll.', 'fehler');
$this->weiter('/installation?id='.rawurlencode($id));
return;
}
$bestand->entfernen($id);
$this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok');
$this->weiter('/');
}
private function fassungNeu(Fassungen $fassungen): void
{
$neu = $fassungen->neueste(true);
$this->hinweis(null === $neu ? 'Die Releaseseite antwortet nicht.' : sprintf('Neueste Fassung: %s.', $neu['version']), null === $neu ? 'fehler' : 'ok');
$this->weiter('/');
}
private function einrichten(Konten $konten, string $methode): void
{
$code = (string) ($this->config['einrichtungscode'] ?? '');
$fehler = null;
if (strlen($code) < 16) {
$fehler = 'In config.php fehlt ein einrichtungscode mit mindestens 16 Zeichen. Erst danach lässt sich das erste Konto anlegen.';
} elseif ('POST' === $methode) {
$name = trim((string) ($_POST['name'] ?? ''));
$passwort = (string) ($_POST['passwort'] ?? '');
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$fehler = match (true) {
!$this->csrfGueltig() => 'Die Sitzung ist abgelaufen. Bitte noch einmal.',
$konten->gesperrt($ip) => 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.',
!hash_equals($code, (string) ($_POST['code'] ?? '')) => 'Der Einrichtungscode stimmt nicht.',
1 !== preg_match('/^[\p{L}\p{N}._@-]{3,60}$/uD', $name) => 'Der Name braucht 3 bis 60 Zeichen (Buchstaben, Ziffern, . _ @ -).',
mb_strlen($passwort) < 12 => 'Das Passwort braucht mindestens 12 Zeichen.',
$passwort !== (string) ($_POST['passwort2'] ?? '') => 'Die beiden Passwörter stimmen nicht überein.',
default => null,
};
if (null !== $fehler && str_starts_with($fehler, 'Der Einrichtungscode')) {
$konten->fehlversuch($ip);
}
if (null === $fehler) {
$konten->anlegen($name, $passwort);
$this->hinweis('Konto angelegt. Bitte jetzt anmelden.', 'ok');
$this->weiter('/anmelden');
return;
}
}
$this->zeigen('einrichten', ['titel' => 'Einrichten', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false);
}
private function anmelden(Konten $konten, string $methode): void
{
if (isset($_SESSION['konto'])) {
$this->weiter('/');
return;
}
$fehler = null;
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
if ('POST' === $methode) {
if (!$this->csrfGueltig()) {
$fehler = 'Die Sitzung ist abgelaufen. Bitte noch einmal.';
} elseif ($konten->gesperrt($ip)) {
$fehler = 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.';
} else {
$konto = $konten->pruefen(trim((string) ($_POST['name'] ?? '')), (string) ($_POST['passwort'] ?? ''), $ip);
if (null !== $konto) {
session_regenerate_id(true);
$_SESSION['konto'] = $konto;
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$this->weiter('/');
return;
}
$fehler = 'Name oder Passwort stimmen nicht.';
}
http_response_code(422);
}
$this->zeigen('anmelden', ['titel' => 'Anmelden', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false);
}
private function abmelden(): void
{
$_SESSION = [];
session_regenerate_id(true);
$this->weiter('/anmelden');
}
private function nichtGefunden(): void
{
http_response_code(404);
$this->zeigen('fehlt', ['titel' => 'Nicht gefunden'], isset($_SESSION['konto']));
}
private function sitzung(): void
{
$https = 'on' === strtolower((string) ($_SERVER['HTTPS'] ?? '')) || '443' === (string) ($_SERVER['SERVER_PORT'] ?? '');
session_name($https ? '__Host-zentrale' : 'zentrale');
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']);
ini_set('session.use_strict_mode', '1');
ini_set('session.gc_maxlifetime', '28800');
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
private function csrfGueltig(): bool
{
return is_string($_POST['_csrf'] ?? null) && hash_equals((string) ($_SESSION['csrf'] ?? ''), $_POST['_csrf']);
}
private function hinweis(string $text, string $art): void
{
$_SESSION['hinweis'] = ['text' => $text, 'art' => $art];
}
private function weiter(string $ziel): void
{
http_response_code(303);
header('Location: '.$ziel);
}
private function kopfzeilen(): void
{
header("Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('X-Robots-Tag: noindex, nofollow');
header('Cache-Control: no-store');
}
/** @param array<string, mixed> $daten */
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void
{
header('Content-Type: text/html; charset=UTF-8');
$hinweis = $_SESSION['hinweis'] ?? null;
unset($_SESSION['hinweis']);
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''));
$inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [
'titel' => $daten['titel'] ?? 'Zentrale',
'inhalt' => $inhalt,
'hinweis' => $hinweis,
'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null,
]);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* POST /api/melden - die Meldung einer KantSites-Installation.
*
* Gegenstück zu App\Ops\Zentrale im CMS. Beim ersten Kontakt bringt die Installation ihren
* Schlüssel mit; danach muss jede Meldung damit unterschrieben sein (HMAC-SHA256 über den
* Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die
* Installation nur Aufträge von hier annimmt.
*/
final class Api
{
private const GROESSTE_MELDUNG = 16384;
public function __construct(private \PDO $db)
{
}
/**
* @return array{status: int, body: string, signatur: ?string}
*/
public function melden(string $body, string $signatur, string $ip): array
{
if (strlen($body) > self::GROESSTE_MELDUNG) {
return $this->fehler(413, 'Die Meldung ist zu groß.');
}
$daten = json_decode($body, true);
if (!is_array($daten)) {
return $this->fehler(400, 'Die Meldung ist kein JSON.');
}
$pruef = $this->pruefen($daten);
if (is_string($pruef)) {
return $this->fehler(400, $pruef);
}
$signatur = strtolower(trim($signatur));
$gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
$gespeichert->execute([$daten['id']]);
$installation = $gespeichert->fetch() ?: null;
$mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null;
if (null === $installation) {
if (null === $mitgebracht) {
// Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt
// daraufhin ihren Schlüssel noch einmal.
return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.');
}
$schluessel = $mitgebracht;
} else {
$schluessel = (string) $installation['schluessel'];
if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) {
return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.');
}
}
if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) {
return $this->fehler(403, 'Die Unterschrift stimmt nicht.');
}
$jetzt = Datenbank::jetzt();
$domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST));
$update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
$werte = [
'adresse' => $daten['adresse'],
'domain' => $domain,
'fassung' => $daten['fassung'],
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
'art' => 'git' === ($daten['art'] ?? '') ? 'git' : 'archiv',
'wartung' => true === ($daten['wartung'] ?? false) ? 1 : 0,
'update_phase' => isset($update['phase']) ? mb_substr((string) $update['phase'], 0, 30) : null,
'update_meldung' => isset($update['meldung']) ? mb_substr((string) $update['meldung'], 0, 300) : null,
'update_zeit' => isset($update['zeit']) ? mb_substr((string) $update['zeit'], 0, 40) : null,
'ip' => mb_substr($ip, 0, 45),
'letzte_meldung' => $jetzt,
];
$this->db->beginTransaction();
try {
if (null === $installation) {
$sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')';
$this->db->prepare($sql)->execute([$daten['id'], $schluessel, $jetzt, ...array_values($werte)]);
} else {
// Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation
// kopiert oder umgezogen. Beides will man sehen.
if ($installation['domain'] !== $domain) {
$werte['domain_vorher'] = $installation['domain'];
}
$sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?';
$this->db->prepare($sql)->execute([...array_values($werte), $daten['id']]);
}
$ergebnis = $daten['auftrag'] ?? null;
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) {
$this->db->prepare('UPDATE auftrag SET erledigt = ?, ok = ?, meldung = ?, abgeholt = COALESCE(abgeholt, ?) WHERE id = ? AND installation = ? AND erledigt IS NULL')
->execute([$jetzt, $ergebnis['ok'] ? 1 : 0, mb_substr((string) ($ergebnis['meldung'] ?? ''), 0, 500), $jetzt, $ergebnis['id'], $daten['id']]);
}
$offen = $this->db->prepare('SELECT id, aktion, fassung FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL ORDER BY id DESC LIMIT 1');
$offen->execute([$daten['id']]);
$auftrag = $offen->fetch() ?: null;
if (null !== $auftrag) {
$this->db->prepare('UPDATE auftrag SET abgeholt = ? WHERE id = ?')->execute([$jetzt, $auftrag['id']]);
$auftrag = ['id' => (int) $auftrag['id'], 'aktion' => (string) $auftrag['aktion'], 'fassung' => (string) $auftrag['fassung']];
}
$this->db->commit();
} catch (\Throwable $fehler) {
$this->db->rollBack();
throw $fehler;
}
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
}
/** @param array<string, mixed> $daten */
private function pruefen(array $daten): ?string
{
if (!is_string($daten['id'] ?? null) || 1 !== preg_match('/^[a-f0-9]{32}$/D', $daten['id'])) {
return 'Die Kennung fehlt oder hat die falsche Form.';
}
$adresse = $daten['adresse'] ?? null;
if (!is_string($adresse) || strlen($adresse) > 200 || 1 !== preg_match('#^https?://[a-z0-9.-]+(:\d{1,5})?(/[^\s]*)?$#iD', $adresse)) {
return 'Die Adresse fehlt oder ist keine Webadresse.';
}
if (!is_string($daten['fassung'] ?? null) || 1 !== preg_match('/^(\d{1,4}\.\d{1,5}\.\d{1,6}|unbekannt)$/D', $daten['fassung'])) {
return 'Die Fassung hat nicht die Form 1.2.3.';
}
return null;
}
/** @return array{status: int, body: string, signatur: null} */
private function fehler(int $status, string $text): array
{
return ['status' => $status, 'body' => (string) json_encode(['ok' => false, 'fehler' => $text], JSON_UNESCAPED_UNICODE), 'signatur' => null];
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die Installationen und ihre Aufträge - was die Übersicht zeigt und was ihre Knöpfe tun.
*/
final class Bestand
{
/** Ohne Meldung seit so vielen Stunden gilt eine Installation als verstummt. */
public const STILL_STUNDEN = 48;
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
public const AUFTRAG_STUNDEN = 2;
public function __construct(private \PDO $db)
{
}
/** @return list<array<string, mixed>> */
public function alle(?string $neueste): array
{
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
$auftraege = [];
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
$auftraege[$auftrag['installation']] = $auftrag;
}
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen);
}
/** @return array<string, mixed>|null */
public function eine(string $id, ?string $neueste): ?array
{
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
$abfrage->execute([$id]);
$zeile = $abfrage->fetch() ?: null;
if (null === $zeile) {
return null;
}
$verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30');
$verlauf->execute([$id]);
$auftraege = $verlauf->fetchAll();
$eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste);
$eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege);
return $eine;
}
/**
* Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht.
*/
public function beauftragen(string $id, string $fassung): ?string
{
$eine = $this->eine($id, $fassung);
if (null === $eine) {
return 'Diese Installation gibt es nicht.';
}
if (!$eine['kann_update']) {
return $eine['kein_update_grund'];
}
$offen = $eine['auftrag'];
if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) {
$this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]);
return null;
}
$this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)')
->execute([$id, 'update', $fassung, Datenbank::jetzt()]);
return null;
}
/** Einen noch nicht abgeholten Auftrag zurückziehen. */
public function zurueckziehen(string $id): bool
{
$abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL');
$abfrage->execute([$id]);
return $abfrage->rowCount() > 0;
}
public function notiz(string $id, string $notiz): void
{
$this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]);
}
public function entfernen(string $id): void
{
$this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]);
}
/**
* @param array<string, mixed> $zeile
* @param array<string, mixed>|null $auftrag
*
* @return array<string, mixed>
*/
private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array
{
$alter = time() - (int) strtotime((string) $zeile['letzte_meldung']);
$veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<');
$zustand = null === $auftrag ? null : $this->auftragZustand($auftrag);
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
$grund = match (true) {
null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
$laeuft => 'Ein Update läuft gerade.',
default => '',
};
return [
...$zeile,
'alter' => $alter,
'still' => $alter > self::STILL_STUNDEN * 3600,
'veraltet' => $veraltet,
'unbekannt' => '' === trim((string) $zeile['notiz']),
'auftrag' => $auftrag,
'auftrag_zustand' => $zustand,
'kann_update' => '' === $grund,
'kein_update_grund' => $grund,
];
}
/**
* @param array<string, mixed> $auftrag
*
* @return array{art: string, text: string}
*/
private function auftragZustand(array $auftrag): array
{
if (null !== $auftrag['erledigt']) {
return (int) $auftrag['ok'] === 1
? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])]
: ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])];
}
if (null === $auftrag['abgeholt']) {
return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])];
}
if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) {
return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])];
}
return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])];
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* SQLite in var/zentrale.db. Das Schema legt sich beim ersten Aufruf selbst an; spätere
* Änderungen kommen als nummerierte Schritte in MIGRATIONEN dazu, nie als Änderung eines alten.
*/
final class Datenbank
{
private const MIGRATIONEN = [
1 => [
'CREATE TABLE konto (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, passwort TEXT NOT NULL, erstellt TEXT NOT NULL)',
'CREATE TABLE anmeldeversuch (ip TEXT NOT NULL, zeit TEXT NOT NULL)',
'CREATE INDEX anmeldeversuch_ip ON anmeldeversuch (ip, zeit)',
'CREATE TABLE installation (
id TEXT PRIMARY KEY,
schluessel TEXT NOT NULL,
adresse TEXT NOT NULL,
domain TEXT NOT NULL,
domain_vorher TEXT,
fassung TEXT NOT NULL,
php TEXT NOT NULL DEFAULT \'\',
datenbank TEXT NOT NULL DEFAULT \'\',
art TEXT NOT NULL DEFAULT \'archiv\',
wartung INTEGER NOT NULL DEFAULT 0,
update_phase TEXT,
update_meldung TEXT,
update_zeit TEXT,
notiz TEXT NOT NULL DEFAULT \'\',
ip TEXT NOT NULL DEFAULT \'\',
erste_meldung TEXT NOT NULL,
letzte_meldung TEXT NOT NULL
)',
'CREATE TABLE auftrag (
id INTEGER PRIMARY KEY,
installation TEXT NOT NULL REFERENCES installation (id) ON DELETE CASCADE,
aktion TEXT NOT NULL,
fassung TEXT NOT NULL,
erstellt TEXT NOT NULL,
abgeholt TEXT,
erledigt TEXT,
ok INTEGER,
meldung TEXT
)',
'CREATE INDEX auftrag_installation ON auftrag (installation, id)',
],
];
public static function oeffnen(string $datei): \PDO
{
$neu = !is_file($datei);
if ($neu && !is_dir(dirname($datei)) && !@mkdir(dirname($datei), 0o775, true)) {
throw new \RuntimeException(dirname($datei).' lässt sich nicht anlegen.');
}
$pdo = new \PDO('sqlite:'.$datei, null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
if ($neu) {
$pdo->exec('PRAGMA journal_mode = WAL');
}
self::migrieren($pdo);
return $pdo;
}
private static function migrieren(\PDO $pdo): void
{
$stand = (int) $pdo->query('PRAGMA user_version')->fetchColumn();
foreach (self::MIGRATIONEN as $nummer => $anweisungen) {
if ($nummer <= $stand) {
continue;
}
$pdo->beginTransaction();
try {
foreach ($anweisungen as $sql) {
$pdo->exec($sql);
}
$pdo->exec('PRAGMA user_version = '.$nummer);
$pdo->commit();
} catch (\Throwable $fehler) {
$pdo->rollBack();
throw $fehler;
}
}
}
public static function jetzt(): string
{
return gmdate('Y-m-d\TH:i:s\Z');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die neueste veröffentlichte KantSites-Fassung - von derselben Releaseseite, die auch die
* Installationen fragen. Eine halbe Stunde zwischengespeichert; ist die Releaseseite nicht
* erreichbar, gilt der letzte bekannte Stand.
*/
final class Fassungen
{
private const GUELTIG = 1800;
public function __construct(private string $api, private string $cache)
{
}
/** @return array{version: string, name: string, published: string}|null */
public function neueste(bool $frisch = false): ?array
{
$gespeichert = json_decode((string) @file_get_contents($this->cache), true);
$gespeichert = is_array($gespeichert) && is_string($gespeichert['version'] ?? null) ? $gespeichert : null;
if (!$frisch && null !== $gespeichert && (int) ($gespeichert['geholt'] ?? 0) > time() - self::GUELTIG) {
return $gespeichert;
}
$neu = $this->holen();
if (null === $neu) {
return $gespeichert;
}
@file_put_contents($this->cache, json_encode([...$neu, 'geholt' => time()]));
return $neu;
}
/** @return array{version: string, name: string, published: string}|null */
private function holen(): ?array
{
if (!str_starts_with($this->api, 'https://')) {
return null;
}
$kontext = stream_context_create([
'http' => ['timeout' => 8, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Zentrale\r\nAccept: application/json\r\n"],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
if (function_exists('curl_init')) {
$curl = curl_init(rtrim($this->api, '/').'/releases/latest');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'KantSites-Zentrale',
]);
$antwort = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$antwort = 200 === $status ? $antwort : false;
} else {
$antwort = @file_get_contents(rtrim($this->api, '/').'/releases/latest', false, $kontext);
}
$daten = is_string($antwort) ? json_decode($antwort, true) : null;
if (!is_array($daten) || 1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', (string) ($daten['tag_name'] ?? ''), $treffer)) {
return null;
}
return ['version' => $treffer[1], 'name' => (string) ($daten['name'] ?? ''), 'published' => (string) ($daten['published_at'] ?? '')];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Anmeldung: Name und Passwort, gehasht mit password_hash() (bcrypt/argon2 je nach PHP).
*
* Nach fünf Fehlversuchen in 15 Minuten nimmt diese IP-Adresse eine Viertelstunde lang keine
* Anmeldung mehr an.
*/
final class Konten
{
private const VERSUCHE = 5;
private const SPERRE_SEKUNDEN = 900;
public function __construct(private \PDO $db)
{
}
public function gibtEs(): bool
{
return (int) $this->db->query('SELECT COUNT(*) FROM konto')->fetchColumn() > 0;
}
public function anlegen(string $name, string $passwort): void
{
$this->db->prepare('INSERT INTO konto (name, passwort, erstellt) VALUES (?, ?, ?)')
->execute([$name, password_hash($passwort, PASSWORD_DEFAULT), Datenbank::jetzt()]);
}
public function gesperrt(string $ip): bool
{
$abfrage = $this->db->prepare('SELECT COUNT(*) FROM anmeldeversuch WHERE ip = ? AND zeit > ?');
$abfrage->execute([$ip, gmdate('Y-m-d\TH:i:s\Z', time() - self::SPERRE_SEKUNDEN)]);
return (int) $abfrage->fetchColumn() >= self::VERSUCHE;
}
/** @return array{id: int, name: string}|null */
public function pruefen(string $name, string $passwort, string $ip): ?array
{
$abfrage = $this->db->prepare('SELECT id, name, passwort FROM konto WHERE name = ?');
$abfrage->execute([$name]);
$konto = $abfrage->fetch() ?: null;
// Auch ohne Konto einmal hashen: Die Antwortzeit verrät sonst, welche Namen es gibt.
$hash = null !== $konto ? (string) $konto['passwort'] : password_hash('attrappe', PASSWORD_DEFAULT);
if (!password_verify($passwort, $hash) || null === $konto) {
$this->fehlversuch($ip);
return null;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$this->db->prepare('UPDATE konto SET passwort = ? WHERE id = ?')->execute([password_hash($passwort, PASSWORD_DEFAULT), $konto['id']]);
}
$this->db->prepare('DELETE FROM anmeldeversuch WHERE ip = ?')->execute([$ip]);
return ['id' => (int) $konto['id'], 'name' => (string) $konto['name']];
}
public function fehlversuch(string $ip): void
{
$this->db->prepare('INSERT INTO anmeldeversuch (ip, zeit) VALUES (?, ?)')->execute([$ip, Datenbank::jetzt()]);
$this->db->prepare('DELETE FROM anmeldeversuch WHERE zeit < ?')->execute([gmdate('Y-m-d\TH:i:s\Z', time() - 86400)]);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Anmelden</h1>
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert" id="fehler"><?= $v->e($fehler) ?></p><?php } ?>
<form method="post" action="/anmelden">
<?= $v->csrf() ?>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="username" required value="<?= $v->e($name) ?>" <?= null !== $fehler ? 'aria-describedby="fehler"' : '' ?>>
<label for="passwort">Passwort</label>
<input id="passwort" name="passwort" type="password" autocomplete="current-password" required>
<button type="submit" class="knopf abstand">Anmelden</button>
</form>
</div>
+19
View File
@@ -0,0 +1,19 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Zentrale einrichten</h1>
<p>Es gibt noch kein Konto. Lege das erste an – dafür brauchst du den Einrichtungscode aus <code>config.php</code>.</p>
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert"><?= $v->e($fehler) ?></p><?php } ?>
<form method="post" action="/">
<?= $v->csrf() ?>
<label for="code">Einrichtungscode</label>
<input id="code" name="code" type="password" autocomplete="off" required>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="username" required minlength="3" maxlength="60" value="<?= $v->e($name) ?>">
<label for="passwort">Passwort</label>
<p class="feldhilfe" id="pw-hilfe">Mindestens 12 Zeichen.</p>
<input id="passwort" name="passwort" type="password" autocomplete="new-password" required minlength="12" aria-describedby="pw-hilfe">
<label for="passwort2">Passwort wiederholen</label>
<input id="passwort2" name="passwort2" type="password" autocomplete="new-password" required minlength="12">
<button type="submit" class="knopf abstand">Konto anlegen</button>
</form>
</div>
+5
View File
@@ -0,0 +1,5 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Nicht gefunden</h1>
<p>Diese Seite gibt es nicht. <a href="/">Zur Übersicht</a></p>
</div>
+100
View File
@@ -0,0 +1,100 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<p class="zurueck"><a href="/">← Alle Installationen</a></p>
<div class="titelzeile">
<h1><?= $v->e($i['domain']) ?></h1>
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
</div>
<div class="raster">
<section class="karte" aria-labelledby="t-stand">
<h2 id="t-stand">Stand</h2>
<dl class="fakten">
<dt>Adresse</dt><dd><a href="<?= $v->e($i['adresse']) ?>" rel="noopener noreferrer"><?= $v->e($i['adresse']) ?></a></dd>
<?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?>
<dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – neueste ist '.$v->e($neueste['version']) : '' ?></dd>
<dt>Aktualisiert über</dt><dd><?= 'git' === $i['art'] ? 'Git-Arbeitskopie' : 'Releasearchiv' ?></dd>
<dt>PHP</dt><dd><?= $v->e($i['php'] ?: '–') ?></dd>
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
<dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></dd>
<dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd>
<dt>Erste Meldung</dt><dd><?= $v->e($v->zeit($i['erste_meldung'])) ?></dd>
<dt>IP-Adresse</dt><dd><?= $v->e($i['ip'] ?: '–') ?></dd>
<?php if (null !== $i['update_phase']) { ?>
<dt>Letztes Update</dt><dd><?= $v->e($i['update_meldung']) ?> <span class="klein">(<?= $v->e($v->zeit($i['update_zeit'])) ?>)</span></dd>
<?php } ?>
</dl>
</section>
<section class="karte" aria-labelledby="t-update">
<h2 id="t-update">Update</h2>
<?php if (null !== $i['auftrag_zustand']) { ?>
<p><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span></p>
<?php } ?>
<?php if ('wartet' === ($i['auftrag_zustand']['art'] ?? null)) { ?>
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab – meist innerhalb von 15 Minuten.</p>
<?php } elseif ($i['kann_update'] && null !== $neueste) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<input type="hidden" name="zurueck" value="detail">
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an, spielt <?= $v->e($neueste['version']) ?> ein und schaltet bei einem Fehler zurück.</p>
<button type="submit" class="knopf">Auf <?= $v->e($neueste['version']) ?> aktualisieren</button>
</form>
<?php } else { ?>
<p><?= $v->e($i['kein_update_grund']) ?></p>
<?php } ?>
<?php if (null !== $i['auftrag'] && null === $i['auftrag']['abgeholt'] && null === $i['auftrag']['erledigt']) { ?>
<form method="post" action="/auftrag/zurueckziehen" class="abstand">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-leise">Auftrag zurückziehen</button>
</form>
<?php } ?>
</section>
<section class="karte" aria-labelledby="t-kunde">
<h2 id="t-kunde">Kunde</h2>
<form method="post" action="/notiz">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<label for="notiz">Kunde oder Notiz</label>
<p class="feldhilfe" id="notiz-hilfe">Installationen ohne Eintrag stehen in der Übersicht als „Unbekannt“ – so fällt eine fremde Nutzung auf.</p>
<input id="notiz" name="notiz" type="text" maxlength="200" value="<?= $v->e($i['notiz']) ?>" aria-describedby="notiz-hilfe">
<button type="submit" class="knopf abstand">Speichern</button>
</form>
</section>
</div>
<section class="karte" aria-labelledby="t-verlauf">
<h2 id="t-verlauf">Aufträge</h2>
<?php if ([] === $i['verlauf']) { ?>
<p>Noch kein Auftrag.</p>
<?php } else { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<thead><tr><th scope="col">Beauftragt</th><th scope="col">Fassung</th><th scope="col">Abgeholt</th><th scope="col">Ergebnis</th></tr></thead>
<tbody>
<?php foreach ($i['verlauf'] as $a) { ?>
<tr>
<td data-label="Beauftragt"><?= $v->e($v->zeit($a['erstellt'])) ?></td>
<td data-label="Fassung"><?= $v->e($a['fassung']) ?></td>
<td data-label="Abgeholt"><?= $v->e($v->zeit($a['abgeholt'])) ?></td>
<td data-label="Ergebnis"><span class="pille p-<?= $v->e($a['zustand']['art']) ?>"><?= $v->e($a['zustand']['text']) ?></span><?php if ('' !== (string) $a['meldung']) { ?><span class="klein block"><?= $v->e($a['meldung']) ?></span><?php } ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>
</section>
<details class="karte gefahr">
<summary>Eintrag entfernen</summary>
<form method="post" action="/entfernen">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<p>Entfernt die Installation und ihre Aufträge aus dieser Liste. An der Website ändert sich nichts; meldet sie sich wieder, erscheint sie neu.</p>
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, <?= $v->e($i['domain']) ?> aus der Liste entfernen</label>
<button type="submit" class="knopf knopf-gefahr">Entfernen</button>
</form>
</details>
+33
View File
@@ -0,0 +1,33 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
<link rel="stylesheet" href="/app.css?v=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
</head>
<body>
<a class="skip" href="#inhalt">Zum Inhalt</a>
<header class="kopf">
<div class="kopf-innen">
<a class="marke" href="/"><span class="marke-zeichen" aria-hidden="true">K</span> KantSites <span>Zentrale</span></a>
<?php if (null !== $konto) { ?>
<form method="post" action="/abmelden" class="abmelden">
<?= $v->csrf() ?>
<span class="wer"><?= $v->e($konto['name']) ?></span>
<button type="submit" class="knopf knopf-leise">Abmelden</button>
</form>
<?php } ?>
</div>
</header>
<main id="inhalt" class="seite" tabindex="-1">
<?php if (null !== $hinweis) { ?>
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
<?php } ?>
<?= $inhalt ?>
</main>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="titelzeile">
<h1>Installationen</h1>
<form method="post" action="/fassung" class="neueste">
<?= $v->csrf() ?>
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span>
<button type="submit" class="knopf knopf-leise">Neu abfragen</button>
</form>
</div>
<ul class="zahlen" aria-label="Zusammenfassung">
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li>
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li>
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li>
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li>
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li>
</ul>
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
<form method="post" action="/auftrag/alle" class="karte alle">
<?= $v->csrf() ?>
<div>
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_column($aktualisierbar, 'domain'))) ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p>
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
</div>
<button type="submit" class="knopf">Alle aktualisieren</button>
</form>
<?php } ?>
<?php if ([] === $installationen) { ?>
<div class="karte leer">
<h2>Noch keine Meldung</h2>
<p>Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.</p>
</div>
<?php } else { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<caption class="unsichtbar">Alle gemeldeten Installationen</caption>
<thead>
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
</thead>
<tbody>
<?php foreach ($installationen as $i) { ?>
<tr>
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
<td data-label="Zustand">
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
</td>
<td class="aktion">
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
</form>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>
+13
View File
@@ -0,0 +1,13 @@
<?php
// Als config.php neben diese Datei kopieren und den Einrichtungscode setzen.
// config.php gehört nicht ins Repository.
return [
// Nur für das erste Konto: Wer ihn kennt, kann das erste Konto anlegen. Danach wird er nicht
// mehr gebraucht. Mindestens 16 Zeichen, zufällig - etwa aus einem Passwortmanager.
'einrichtungscode' => '',
// Wo die neueste KantSites-Fassung steht. Dieselbe Releaseseite fragen die Installationen.
'releases_api' => 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
];
+15
View File
@@ -0,0 +1,15 @@
Options -Indexes
DirectoryIndex index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP_HOST} !^(localhost|127\.0\.0\.1)(:\d+)?$
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
</IfModule>
+159
View File
@@ -0,0 +1,159 @@
:root {
--grund: #f4f6f9;
--flaeche: #ffffff;
--text: #17202b;
--leise: #5a6575;
--linie: #dde3ea;
--marke: #173f6b;
--marke-hell: #e6eef7;
--ok: #17663a;
--ok-grund: #e3f4e9;
--warn: #8a4b00;
--warn-grund: #fdf0dc;
--fehler: #a4161a;
--fehler-grund: #fde7e7;
--still: #5b4a8a;
--still-grund: #eeeaf8;
--fokus: #1f6feb;
--radius: 10px;
color-scheme: light;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--grund);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--marke); text-underline-offset: 2px; }
a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 3px solid var(--fokus); outline-offset: 2px; border-radius: 4px; }
h1 { font-size: 1.6rem; line-height: 1.25; margin: 0; }
h2 { font-size: 1.05rem; margin: 0 0 .6rem; }
p { margin: 0 0 .8rem; }
code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font-size: .92em; }
.skip { position: absolute; left: -9999px; top: 8px; background: var(--flaeche); padding: 8px 12px; border-radius: 6px; z-index: 10; }
.skip:focus { left: 16px; }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.kopf { background: var(--marke); color: #fff; }
.kopf-innen { max-width: 1180px; margin: 0 auto; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.marke { color: #fff; text-decoration: none; font-weight: 700; font-size: 1.05rem; display: inline-flex; align-items: center; gap: 10px; }
.marke span:last-child { font-weight: 400; opacity: .85; }
.marke-zeichen { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; background: #fff; color: var(--marke); font-weight: 800; }
.abmelden { display: flex; align-items: center; gap: 10px; margin: 0; }
.wer { font-size: .9rem; opacity: .9; }
.kopf .knopf-leise { color: #fff; border-color: rgba(255, 255, 255, .5); background: transparent; }
.kopf .knopf-leise:hover { background: rgba(255, 255, 255, .12); }
.kopf :focus-visible { outline-color: #fff; }
.seite { max-width: 1180px; margin: 0 auto; padding: 24px 16px 48px; }
.seite:focus { outline: none; }
.titelzeile { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-bottom: 18px; }
.neueste { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--leise); flex-wrap: wrap; }
.neueste strong { color: var(--text); }
.zurueck { margin-bottom: 8px; }
.karte { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 18px; }
.schmal { max-width: 440px; margin: 32px auto; }
.schmal h1 { margin-bottom: 14px; }
.raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 18px; align-items: start; }
.zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; }
.zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; }
.z-ok strong { color: var(--ok); }
.z-warn strong { color: var(--warn); }
.z-still strong { color: var(--still); }
.z-fremd strong { color: var(--fehler); }
.alle { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-left: 4px solid var(--marke); }
.alle > div { flex: 1 1 420px; }
.alle p { color: var(--leise); }
.bestaetigen { display: flex; align-items: center; gap: 8px; font-weight: 600; margin: 4px 0 0; }
.bestaetigen input { width: 18px; height: 18px; margin: 0; accent-color: var(--marke); }
.tabelle-rahmen { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); overflow: hidden; }
.karte .tabelle-rahmen { border: 0; border-radius: 0; }
.tabelle { width: 100%; border-collapse: collapse; }
.tabelle th, .tabelle td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--linie); vertical-align: middle; }
.tabelle thead th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--leise); background: #f9fafc; font-weight: 600; }
.tabelle tbody tr:last-child > * { border-bottom: 0; }
.tabelle tbody th { font-weight: 600; }
.tabelle tbody tr:hover { background: #fafbfd; }
.aktion { text-align: right; white-space: nowrap; }
.aktion form { margin: 0; }
.klein { color: var(--leise); font-size: .85rem; }
.block { display: block; margin-top: 3px; }
th .klein { display: block; font-weight: 400; }
.still { color: var(--still); font-weight: 600; }
.fassung { font-variant-numeric: tabular-nums; font-weight: 600; padding: 2px 8px; border-radius: 999px; font-size: .9rem; }
.f-ok { background: var(--ok-grund); color: var(--ok); }
.f-alt { background: var(--warn-grund); color: var(--warn); }
.pille { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 600; }
.p-ok { background: var(--ok-grund); color: var(--ok); }
.p-fehler { background: var(--fehler-grund); color: var(--fehler); }
.p-warn, .p-wartet { background: var(--warn-grund); color: var(--warn); }
.p-laeuft { background: var(--marke-hell); color: var(--marke); }
.marke-fremd { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 700; background: var(--fehler-grund); color: var(--fehler); }
.fakten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
.fakten dt { color: var(--leise); }
.fakten dd { margin: 0; overflow-wrap: anywhere; }
label { display: block; font-weight: 600; margin: 12px 0 4px; }
.feldhilfe { color: var(--leise); font-size: .88rem; margin: -2px 0 6px; }
input[type="text"], input[type="password"] {
width: 100%; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text);
}
input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-color: var(--fokus); }
.knopf {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 8px 16px;
font: inherit; font-weight: 600; color: #fff; background: var(--marke); border: 1px solid var(--marke); border-radius: 8px; cursor: pointer;
}
.knopf:hover { background: #0f2f52; }
.knopf-klein { min-height: 34px; padding: 5px 12px; font-size: .9rem; }
.knopf-leise { background: var(--flaeche); color: var(--marke); border-color: #b9c3cf; min-height: 34px; padding: 5px 12px; font-size: .9rem; }
.knopf-leise:hover { background: var(--marke-hell); }
.knopf-gefahr { background: var(--fehler); border-color: var(--fehler); }
.knopf-gefahr:hover { background: #7f1013; }
.abstand { margin-top: 14px; }
.hinweis { padding: 10px 14px; border-radius: 8px; margin-bottom: 18px; font-weight: 500; }
.hinweis-ok { background: var(--ok-grund); color: var(--ok); }
.hinweis-fehler { background: var(--fehler-grund); color: var(--fehler); }
.gefahr summary { cursor: pointer; font-weight: 600; color: var(--fehler); }
.gefahr[open] summary { margin-bottom: 10px; }
.gefahr .knopf { margin-top: 12px; }
.leer p { color: var(--leise); margin: 0; }
@media (max-width: 760px) {
h1 { font-size: 1.35rem; }
.karte { padding: 16px; }
.tabelle thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.tabelle, .tabelle tbody, .tabelle tr, .tabelle th, .tabelle td { display: block; width: 100%; }
.tabelle tr { padding: 10px 14px; border-bottom: 1px solid var(--linie); }
.tabelle tbody tr:last-child { border-bottom: 0; }
.tabelle th, .tabelle td { border: 0; padding: 3px 0; }
.tabelle td[data-label]::before { content: attr(data-label) ": "; color: var(--leise); font-size: .85rem; }
.tabelle tbody th { font-size: 1.05rem; padding-bottom: 6px; }
.aktion { text-align: left; padding-top: 8px !important; }
.fakten { grid-template-columns: 1fr; gap: 0; }
.fakten dd { margin-bottom: 8px; }
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
$wurzel = dirname(__DIR__);
// Der eingebaute Server von PHP liefert Dateien aus public/ selbst aus.
if ('cli-server' === PHP_SAPI && is_file(__DIR__.parse_url((string) $_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
return false;
}
if (PHP_VERSION_ID < 80100) {
http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
echo "Die Zentrale braucht PHP 8.1 oder neuer.\n";
exit;
}
spl_autoload_register(static function (string $klasse) use ($wurzel): void {
if (str_starts_with($klasse, 'Zentrale\\')) {
$datei = $wurzel.'/app/'.substr($klasse, 9).'.php';
if (is_file($datei)) {
require $datei;
}
}
});
$config = is_file($wurzel.'/config.php') ? require $wurzel.'/config.php' : [];
$config = is_array($config) ? $config : [];
$config += ['releases_api' => 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites', 'einrichtungscode' => ''];
(new Zentrale\Anwendung($config, $wurzel))->bearbeiten();
+1
View File
@@ -0,0 +1 @@
Require all denied