Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592e3d5342 | ||
|
|
8ed3cdb51f | ||
|
|
d35cab163e | ||
|
|
968d19b611 | ||
|
|
9d80af3795 | ||
|
|
d3ee8e3d1e | ||
|
|
6b99781819 | ||
|
|
6cfbc6cf7e | ||
|
|
bb736e8258 | ||
|
|
7cdb431fd4 | ||
|
|
821c799225 |
No files matched your search
Generated
+267
-266
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,14 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026)
|
||||
|
||||
**Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht.
|
||||
|
||||
**Entscheidung:** Dieselbe Tabelle `installation` mit neuer Spalte `produkt` (Migration 2 der Zentrale; Altbestand wird `kantsites`), dazu `laufzeit`, `mandanten`, `stoerungen`, `speicher`. Eigener Endpunkt `POST /api/kanttresor/melden` (`Api::meldenTresor`) mit demselben Schlüssel-/HMAC-Verfahren wie ADR 123; eine Kennung bleibt an ihr Produkt gebunden (403 sonst). Die Antwort ist nur `{"ok":true}` – nie ein Auftrag, und `Bestand::beauftragen` lehnt KantTresor-Kennungen ab. Übersicht unter `/kanttresor` mit eigenen Spalten und der Kachel „Dienst gestört oder Speicher knapp“ (≥ 90 %). Als neueste Fassung gilt die höchste gemeldete, weil die KantArchiv-Releaseseite nicht öffentlich ist und ihre Tags nicht gepflegt sind. Gegenstück in KantArchiv: `controlplane/zentrale.py`, beat-Task alle 15 min, ab 1.82.0.
|
||||
|
||||
**Folgen:** Die Zentrale muss neu hochgeladen werden; die Datenbank migriert beim ersten Aufruf. „Veraltet“ ist bei KantTresor relativ zum Bestand – ist keine Instanz auf dem neuesten Stand, fällt das nicht auf. KantTresor gibt keine Tür nach außen frei; er sendet nur. Die IP-Adresse wird wie bei KantSites gespeichert.
|
||||
|
||||
## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
|
||||
|
||||
|
||||
+65
-1
@@ -1,5 +1,67 @@
|
||||
# Projektstatus
|
||||
|
||||
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-11.md`, nicht in Git): `symfony/mime` 7.4.19 betroffen von CVE-2026-45070 (E-Mail-Header-Injection über Parameternamen, behoben ab 7.4.22), in `main` und in v1.36.1. KantSites baut keine MIME-Parameternamen aus Eingaben, Ausnutzbarkeit gering. Umut: „Ja, Update + Release“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.x → 7.4.22 (mime, mailer, framework-bundle, http-kernel, security-*, console, yaml, rate-limiter u. a.), Polyfills 1.43.3, masterminds/html5 2.12.0. `npm audit fix` für die Entwicklungswerkzeuge (zod 3.25 → 4.6.7 unter lighthouse/puppeteer-core). `composer.json` unverändert, kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` und `npm audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4; unter 8.2 nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (vorbestehend). php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt).
|
||||
|
||||
**Release:** Tag `v1.36.2`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (3cf4e8c9…), `releases/latest` meldet v1.36.2. Im Archiv steckt `symfony/mime` v7.4.22.
|
||||
|
||||
**Weiter offen aus dem Check:** HSTS auf den Kundenseiten einschalten (System → HSTS); PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.36.2.
|
||||
|
||||
## Zentrale: Reiter KantTresor (27.10.2026)
|
||||
|
||||
**Anlass:** Umut: KantTresor soll sich in der Zentrale melden, eigener Reiter; „was wir nicht brauchen ist Aktualisierung per Knopfdruck“.
|
||||
|
||||
**Erledigt:** `zentrale/`: Migration 2 (`produkt`, `laufzeit`, `mandanten`, `stoerungen`, `speicher`; Altbestand = KantSites), Endpunkt `POST /api/kanttresor/melden`, Reiter KantSites/KantTresor, Übersicht `/kanttresor` mit Kacheln (inkl. „Dienst gestört oder Speicher knapp“), Live-Suche, Detailseite ohne Update- und Auftragsteil, serverseitige Ablehnung von Aufträgen für KantTresor. ADR 124, `zentrale/README.md`. KantArchiv (eigenes Repo): `controlplane/zentrale.py`, Modell `ZentraleMeldung` (Migration 0016), beat-Task `controlplane.zentrale_melden` alle 15 min, Einstellungen `ZENTRALE_URL`/`ZENTRALE_ADRESSE`/`ZENTRALE_POLL_SECONDS`, Tests – dort als 1.82.0 committet und gepusht (fc2019b, b261658).
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` 28/28 (11 neue zu KantTresor). KantArchiv: `tests.test_zentrale` + `test_manage_gui` 24/24, ruff, mypy, `migrate_all` im Dev-Stack. Ende-zu-Ende: echte Meldung aus dem KantTresor-Container per HTTP an die lokale Zentrale (200, Antwortunterschrift gültig). Upgrade einer Zentrale-DB im Stand 1. Browser (Port 8115): Reiter, Filter, Live-Suche, Zurücksetzen bleibt auf `/kanttresor`, Detailseite, Notiz, untergeschobener Update-Auftrag wird abgelehnt, KantSites-Reiter unverändert mit Update-Knöpfen, 375 px ohne Querscrollen, keine Konsolenfehler. Demo-DB wieder gelöscht. Nicht geprüft: Live-Zentrale, Meldung einer echten KantTresor-Produktion über das Internet.
|
||||
|
||||
**Fund unterwegs:** Der Dev-Stack von KantArchiv hat `host.docker.internal` in `DJANGO_ALLOWED_HOSTS` und hätte sich sonst an die echte Zentrale gemeldet. Jetzt gesperrt über `.internal` und `DJANGO_DEBUG=True`; vor dem ersten Takt gestoppt, es ging nichts raus.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (`config.php` und `var/` bleiben). KantTresor-Instanzen mit `./update.sh` auf 1.82.0 heben; nach spätestens 15 min erscheinen sie im Reiter. KantSites-Änderungen sind noch nicht committet.
|
||||
|
||||
## Zentrale: Warnung bei unsicherem PHP (22.10.2026)
|
||||
|
||||
**Anlass:** Kritische PHP-Lücke „php2shell“ (CVE-2026-109618/-109619, CVSS 9.2, Patches vom 21.10.2026: 8.2.35, 8.3.36, 8.4.27, 8.5.12). Umut: Warnung „bei sitesviewer einbauen, nicht in KantSites selbst“.
|
||||
|
||||
**Erledigt:** Nur `zentrale/`, kein Eingriff ins CMS, keine Schemaänderung. `Bestand::PHP_SICHER` (erste sichere Fassung je Zweig) und `PHP_ALT_BIS` (8.1 und älter ohne Updates); `Bestand::phpLuecke()` liefert den Grund oder null. Unbekannte Angaben und neuere Zweige (8.6-Vorabfassungen) gelten nicht als unsicher. Übersicht: neue Kachel/Filter „PHP mit Sicherheitslücke“, rote Pille „PHP x.y.z unsicher“ in der Spalte Zustand, Suche findet auch die PHP-Fassung. Detailseite: Pille plus Hinweis, dass das Update beim Hoster liegt. Bei der nächsten PHP-Sicherheitsmeldung `PHP_SICHER` nachziehen.
|
||||
|
||||
**Geprüft:** `php scripts/zentrale-check.php` grün (drei neue Prüfungen zur PHP-Regel). Lokal im Browser (Port 8115, drei Demo-Installationen mit 8.4.22, 8.4.27, 8.1.30): Kachel zählt 2, Filter zeigt genau die zwei, Detailseite mit Hinweis. Demo-Datenbank wieder gelöscht. Nicht geprüft: Mobilansicht, Live-Zentrale.
|
||||
|
||||
**Bekannte Grenze:** Distributionen mit zurückportierten Patches (z. B. Debian/Ubuntu) melden weiter die alte Versionsnummer und erscheinen dann fälschlich als unsicher. Installationen vor 1.36.0 melden kein PHP.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`, neu gebaut); `config.php` und `var/` auf dem Server bleiben. PHP bei allen Hostings auf 8.4.27 bringen.
|
||||
|
||||
## Symfony-Patchupdate, Release 1.36.1 (14.10.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-10.md`, nicht in Git): keine Sicherheitslücken, aber Patch-Rückstand bei Symfony. Umut: „dann mach neueste symfony version dann kann ich den updater mal testen“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.15–7.4.18 → 7.4.19–7.4.21 (framework-bundle, http-kernel, security-*, twig-bundle, console, yaml u. a.), Polyfills 1.43.0, twig/twig 3.28 → 3.30, doctrine/lexer 3.0.3. Bewusst bei der LTS-Linie 7.4 geblieben (Sicherheitssupport bis 11/2029); 8.x wäre ein Major-Sprung mit PHP-8.4-Pflicht. `composer.json` unverändert, Plattform weiter PHP 8.2.12. Kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4 und 8.2; unter 8.2 eine Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (`file_get_contents` auf eine von `git checkout` entfernte `.env`, `Updater.php:468`), die mit der alten `composer.lock` genauso auftritt – vorbestehend. php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün (erster Lauf hing einmal beim Warten auf „Veröffentlicht“, zweiter Lauf vollständig grün), `test:browser` gegen 8080 grün (zwei Kontrastbefunde aus im Editor gesetzten Farben auf `/grundlagen`, Inhalt, nicht Code).
|
||||
|
||||
**Release:** Tag `v1.36.1`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (fe286f49…), `releases/latest` meldet v1.36.1.
|
||||
|
||||
**Nächster Schritt:** Umut testet den Updater: eine Installation auf 1.36.0 per „System“ oder über die Zentrale auf 1.36.1 heben. Weitere Punkte aus dem Sicherheitscheck: HSTS auf den Kundenseiten einschalten (System → HSTS), PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
## Zentrale: Suche, Filter und Seiten (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du Suche und Pagination dort einbauen bei Zentrale“.
|
||||
|
||||
**Erledigt:** Übersicht in `zentrale/` mit Suche (Domain, frühere Domain, Kunde/Notiz, Fassung, IP), Zählerkacheln als Filter (mit Markierung und `aria-current`), Seiten zu je 25 Einträgen mit Zurück/Weiter und Auslassungen, Trefferzeile als Statusmeldung, eigener Leerzustand „Nichts gefunden“. Domains natürlich sortiert, „www.“ zählt nicht. „Alle aktualisieren“ nennt höchstens acht Domains und „und N weitere“. Kein Eingriff ins CMS, keine Schemaänderung.
|
||||
|
||||
**Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün.
|
||||
|
||||
**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet.
|
||||
|
||||
## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch.
|
||||
@@ -10,7 +72,9 @@
|
||||
|
||||
**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.
|
||||
**Release:** sites.kant-it.de ist online (Umut; `/api/melden` antwortet, `config.php` nicht abrufbar). Fassung 1.36.0 am 14.10.2026 veröffentlicht: Tag `v1.36.0`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (688a0689…), `releases/latest` meldet v1.36.0.
|
||||
|
||||
**Nächster Schritt:** kant-it.de und die übrigen Live-Installationen unter „System“ auf 1.36.0 heben, erste Meldung in der Zentrale prüfen, das erste Fern-Update mit der nächsten Fassung ausprobieren.
|
||||
|
||||
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
|
||||
|
||||
|
||||
Generated
+47
-47
@@ -213,13 +213,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
|
||||
"integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"modern-tar": "^0.8.6",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -242,9 +242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||
"integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -308,9 +308,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -377,9 +377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers/node_modules/yargs": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -608,14 +608,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"version": "157.0.8090-0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
|
||||
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
@@ -812,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1029,9 +1029,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
|
||||
"integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1100,34 +1100,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.10.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
|
||||
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
|
||||
"version": "25.16.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
|
||||
"integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
"@puppeteer/browsers": "3.2.6",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1704951",
|
||||
"typed-query-selector": "^2.12.3",
|
||||
"webdriver-bidi-protocol": "1.0.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
||||
"version": "0.0.1666840",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
|
||||
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
|
||||
"version": "0.0.1704951",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
|
||||
"integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1285,9 +1285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
|
||||
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1306,9 +1306,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
|
||||
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -1412,9 +1412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
||||
@@ -105,5 +105,37 @@ $umzug->melden();
|
||||
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
|
||||
$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar');
|
||||
|
||||
// PHP-Fassungen mit bekannter Lücke fallen auf; gepatchte, unbekannte und Vorabfassungen nicht.
|
||||
$pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Bestand::phpLuecke('8.1.30'), 'Ungepatchtes und veraltetes PHP gilt als unsicher');
|
||||
$pruefe(null === Zentrale\Bestand::phpLuecke('8.4.27') && null === Zentrale\Bestand::phpLuecke('8.6.0RC4') && null === Zentrale\Bestand::phpLuecke(''), 'Gepatchtes, neueres und unbekanntes PHP gilt nicht als unsicher');
|
||||
$pruefe(array_key_exists('php_luecke', $bestand->alle(null)[0] ?? []), 'Die Übersicht kennt den PHP-Zustand');
|
||||
|
||||
// KantTresor (ADR 124): eigener Pfad, gleiches Verfahren, nie ein Auftrag. Die Gegenseite ist
|
||||
// Python (controlplane/zentrale.py in KantArchiv); hier wird sie mit denselben Feldern nachgestellt.
|
||||
$tresorId = bin2hex(random_bytes(16));
|
||||
$tresorSchluessel = bin2hex(random_bytes(32));
|
||||
$tresorMelden = static function (array $daten, ?string $schluessel = null) use ($api, $tresorSchluessel): array {
|
||||
$body = (string) json_encode($daten, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $api->meldenTresor($body, hash_hmac('sha256', $body, $schluessel ?? $tresorSchluessel), '198.51.100.4');
|
||||
};
|
||||
$tresorDaten = ['id' => $tresorId, 'adresse' => 'https://archiv.kunde-drei.de', 'fassung' => '1.82.0', 'laufzeit' => 'Python 3.12.7 · Django 5.2.8', 'datenbank' => 'postgresql', 'mandanten' => 3, 'stoerungen' => ['Objektspeicher'], 'speicher' => 93];
|
||||
$antwort = $tresorMelden([...$tresorDaten, 'schluessel' => $tresorSchluessel]);
|
||||
$pruefe(200 === $antwort['status'] && hash_equals(hash_hmac('sha256', $antwort['body'], $tresorSchluessel), (string) $antwort['signatur']), 'KantTresor-Meldung wird angenommen und unterschrieben beantwortet');
|
||||
$pruefe(!str_contains($antwort['body'], 'auftrag'), 'KantTresor bekommt nie einen Auftrag');
|
||||
$tresorZeile = $bestand->eine($tresorId, '1.82.0');
|
||||
$pruefe('kanttresor' === ($tresorZeile['produkt'] ?? null) && 3 === (int) $tresorZeile['mandanten'] && 'Objektspeicher' === $tresorZeile['stoerungen'], 'Mandanten und Störungen stehen in der Zentrale');
|
||||
$pruefe(true === $tresorZeile['gestoert'] && true === $tresorZeile['speicher_knapp'], 'Gestörter Dienst und knapper Speicher fallen auf');
|
||||
$pruefe(false === $tresorZeile['kann_update'] && null !== $bestand->beauftragen($tresorId, '1.83.0'), 'Für KantTresor lässt sich kein Update beauftragen');
|
||||
$pruefe(1 === count($bestand->alle(null, 'kanttresor')) && [] === array_filter($bestand->alle(null), static fn (array $i): bool => $i['id'] === $tresorId), 'KantSites- und KantTresor-Liste bleiben getrennt');
|
||||
$pruefe(200 === $tresorMelden([...$tresorDaten, 'stoerungen' => [], 'speicher' => 41])['status'] && false === $bestand->eine($tresorId, null)['gestoert'], 'Folgemeldung ohne Schlüssel, Störung behoben');
|
||||
$pruefe(403 === $tresorMelden($tresorDaten, str_repeat('0', 64))['status'], 'KantTresor-Meldung mit falscher Unterschrift wird abgewiesen');
|
||||
$pruefe(409 === $tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16))])['status'], 'Unbekannte KantTresor-Kennung ohne Schlüssel bekommt 409');
|
||||
$kantsitesId = (string) $pdo->query("SELECT id FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$kantsitesSchluessel = (string) $pdo->query("SELECT schluessel FROM installation WHERE produkt = 'kantsites'")->fetchColumn();
|
||||
$pruefe(403 === $tresorMelden([...$tresorDaten, 'id' => $kantsitesId], $kantsitesSchluessel)['status'], 'Eine KantSites-Kennung kann nicht als KantTresor melden');
|
||||
$tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16)), 'schluessel' => $tresorSchluessel, 'fassung' => '1.83.1']);
|
||||
$pruefe('1.83.1' === $bestand->hoechsteFassung('kanttresor') && true === $bestand->eine($tresorId, '1.83.1')['veraltet'], 'Höchste gemeldete KantTresor-Fassung macht ältere zu veraltet');
|
||||
|
||||
echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
|
||||
exit($fehler > 0 ? 1 : 0);
|
||||
@@ -37,6 +37,14 @@ Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `12
|
||||
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
|
||||
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Zählerkacheln oben sind zugleich Filter (aktuell, veraltet, verstummt, ohne Kunde). Die Suche
|
||||
findet Domain, frühere Domain, Kunde/Notiz, Fassung und IP-Adresse; Suche und Filter lassen sich
|
||||
kombinieren. Die Suche filtert schon beim Tippen; Kacheln und Seitenzahlen wechseln nur die
|
||||
Liste (`public/app.js`, ohne JavaScript funktioniert alles als normales Formular). 25 Installationen
|
||||
je Seite, Domains natürlich sortiert und ohne „www.“.
|
||||
|
||||
## Update aus der Ferne
|
||||
|
||||
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
|
||||
@@ -53,6 +61,27 @@ Einschränkungen:
|
||||
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“.
|
||||
|
||||
## KantTresor
|
||||
|
||||
Der Reiter **KantTresor** (`/kanttresor`) zeigt die KantTresor-Instanzen. Ab KantTresor
|
||||
**1.82.0** meldet sich jede Instanz alle 15 Minuten über den beat-Task
|
||||
`controlplane.zentrale_melden` an `POST /api/kanttresor/melden` – gleiches Verfahren wie
|
||||
KantSites (Schlüssel bei der ersten Meldung, danach HMAC-Unterschrift in beide Richtungen).
|
||||
|
||||
Übertragen werden: Adresse, Fassung, Python/Django, Datenbank, Anzahl aktiver Mandanten,
|
||||
Namen gestörter Dienste und belegter Speicher in Prozent. Keine Mandantennamen, Dokumente
|
||||
oder Zugangsdaten.
|
||||
|
||||
Unterschiede zu KantSites:
|
||||
|
||||
- **Kein Update von hier.** Die Zentrale gibt KantTresor nie einen Auftrag; aktualisiert wird
|
||||
auf dem Server mit `./update.sh`.
|
||||
- „Aktuell“ und „veraltet“ richten sich nach der **höchsten gemeldeten Fassung** – die
|
||||
KantArchiv-Releaseseite ist nicht öffentlich.
|
||||
- Statt der PHP-Kachel gibt es **„Dienst gestört oder Speicher knapp“** (ab 90 % belegt).
|
||||
- Abschalten pro Instanz: `ZENTRALE_URL=aus` in `.env.prod`. Instanzen mit
|
||||
`DJANGO_DEBUG=True` oder nur lokaler Adresse melden nie.
|
||||
|
||||
## Fremde Installationen erkennen
|
||||
|
||||
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace Zentrale;
|
||||
*/
|
||||
final class Ansicht
|
||||
{
|
||||
public function __construct(private string $csrf)
|
||||
/** @param string $pfad die Übersicht, auf die adresse() zeigt (KantSites oder KantTresor) */
|
||||
public function __construct(private string $csrf, private string $pfad = '/')
|
||||
{
|
||||
}
|
||||
|
||||
@@ -34,6 +35,18 @@ final class Ansicht
|
||||
return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Adresse der Übersicht; leere Werte und Seite 1 fallen weg.
|
||||
*
|
||||
* @param array<string, mixed> $werte
|
||||
*/
|
||||
public function adresse(array $werte): string
|
||||
{
|
||||
$werte = array_filter($werte, static fn (mixed $wert): bool => null !== $wert && '' !== $wert && 1 !== $wert);
|
||||
|
||||
return $this->pfad.([] === $werte ? '' : '?'.http_build_query($werte));
|
||||
}
|
||||
|
||||
/** Ein Zeitpunkt in deutscher Ortszeit. */
|
||||
public function zeit(?string $iso): string
|
||||
{
|
||||
|
||||
+109
-27
@@ -10,6 +10,9 @@ namespace Zentrale;
|
||||
*/
|
||||
final class Anwendung
|
||||
{
|
||||
/** Zeilen je Seite in der Übersicht. */
|
||||
private const PRO_SEITE = 25;
|
||||
|
||||
private \PDO $db;
|
||||
|
||||
/** @param array<string, mixed> $config */
|
||||
@@ -34,8 +37,8 @@ final class Anwendung
|
||||
return;
|
||||
}
|
||||
|
||||
if ('/api/melden' === $pfad) {
|
||||
$this->api($methode);
|
||||
if ('/api/melden' === $pfad || '/api/kanttresor/melden' === $pfad) {
|
||||
$this->api($methode, '/api/melden' === $pfad ? 'kantsites' : 'kanttresor');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +77,8 @@ final class Anwendung
|
||||
$bestand = new Bestand($this->db);
|
||||
|
||||
match (true) {
|
||||
'/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen),
|
||||
'/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen->neueste(), 'kantsites'),
|
||||
'/kanttresor' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $this->tresorNeueste($bestand), 'kanttresor'),
|
||||
'/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),
|
||||
@@ -87,7 +91,7 @@ final class Anwendung
|
||||
};
|
||||
}
|
||||
|
||||
private function api(string $methode): void
|
||||
private function api(string $methode, string $produkt): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
if ('POST' !== $methode) {
|
||||
@@ -97,7 +101,8 @@ final class Anwendung
|
||||
|
||||
return;
|
||||
}
|
||||
$antwort = (new Api($this->db))->melden(
|
||||
$api = new Api($this->db);
|
||||
$antwort = ('kanttresor' === $produkt ? $api->meldenTresor(...) : $api->melden(...))(
|
||||
(string) file_get_contents('php://input', false, null, 0, 16385),
|
||||
(string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''),
|
||||
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
@@ -109,36 +114,106 @@ final class Anwendung
|
||||
echo $antwort['body'];
|
||||
}
|
||||
|
||||
private function uebersicht(Bestand $bestand, Fassungen $fassungen): void
|
||||
/**
|
||||
* KantTresor hat keine öffentliche Releaseseite; die höchste gemeldete Fassung gilt als neueste.
|
||||
*
|
||||
* @return array{version: string}|null
|
||||
*/
|
||||
private function tresorNeueste(Bestand $bestand): ?array
|
||||
{
|
||||
$neueste = $fassungen->neueste();
|
||||
$alle = $bestand->alle($neueste['version'] ?? null);
|
||||
$this->zeigen('uebersicht', [
|
||||
'titel' => 'Installationen',
|
||||
$hoechste = $bestand->hoechsteFassung('kanttresor');
|
||||
|
||||
return null === $hoechste ? null : ['version' => $hoechste];
|
||||
}
|
||||
|
||||
/** @param array{version: string}|null $neueste */
|
||||
private function uebersicht(Bestand $bestand, ?array $neueste, string $produkt): void
|
||||
{
|
||||
$tresor = 'kanttresor' === $produkt;
|
||||
$alle = $bestand->alle($neueste['version'] ?? null, $produkt);
|
||||
|
||||
// Die Zähler oben sind zugleich die Filter; sie zählen immer den ganzen Bestand.
|
||||
$filter = [
|
||||
'aktuell' => static fn (array $i): bool => null !== $neueste && !$i['veraltet'] && 'unbekannt' !== $i['fassung'],
|
||||
'veraltet' => static fn (array $i): bool => $i['veraltet'],
|
||||
'still' => static fn (array $i): bool => $i['still'],
|
||||
'unbekannt' => static fn (array $i): bool => $i['unbekannt'],
|
||||
];
|
||||
if ($tresor) {
|
||||
$filter['gestoert'] = static fn (array $i): bool => $i['gestoert'];
|
||||
} else {
|
||||
$filter['php'] = static fn (array $i): bool => null !== $i['php_luecke'];
|
||||
}
|
||||
$zahlen = ['alle' => count($alle)];
|
||||
foreach ($filter as $name => $passt) {
|
||||
$zahlen[$name] = count(array_filter($alle, $passt));
|
||||
}
|
||||
if (null === $neueste) {
|
||||
$zahlen['aktuell'] = null;
|
||||
}
|
||||
|
||||
$suche = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
|
||||
$gewaehlt = is_string($_GET['filter'] ?? null) && isset($filter[$_GET['filter']]) ? $_GET['filter'] : null;
|
||||
$treffer = array_values(array_filter($alle, static function (array $i) use ($suche, $gewaehlt, $filter): bool {
|
||||
if (null !== $gewaehlt && !$filter[$gewaehlt]($i)) {
|
||||
return false;
|
||||
}
|
||||
if ('' === $suche) {
|
||||
return true;
|
||||
}
|
||||
foreach (['domain', 'domain_vorher', 'adresse', 'notiz', 'fassung', 'php', 'laufzeit', 'stoerungen', 'ip'] as $feld) {
|
||||
if (null !== $i[$feld] && false !== mb_stripos((string) $i[$feld], $suche)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
|
||||
$proSeite = self::PRO_SEITE;
|
||||
$seiten = max(1, (int) ceil(count($treffer) / $proSeite));
|
||||
$seite = min($seiten, max(1, (int) ($_GET['seite'] ?? 1)));
|
||||
|
||||
$namen = ['aktuell' => 'aktuell', 'veraltet' => 'veraltet', 'still' => 'ohne Meldung seit 48 h', 'unbekannt' => 'ohne Kundenzuordnung', 'php' => 'PHP mit Sicherheitslücke', 'gestoert' => 'Dienst gestört oder Speicher knapp'];
|
||||
$von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1;
|
||||
$bis = min(count($treffer), $seite * $proSeite);
|
||||
$einheit = $tresor ? (1 === count($treffer) ? 'Instanz' : 'Instanzen') : (1 === count($treffer) ? 'Installation' : 'Installationen');
|
||||
$trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), $einheit)
|
||||
.(null !== $gewaehlt ? ' · Filter: '.$namen[$gewaehlt] : '')
|
||||
.('' !== $suche ? ' · Suche: „'.$suche.'“' : '');
|
||||
|
||||
// Die Live-Suche holt nur die Liste; ohne JavaScript kommt die ganze Seite.
|
||||
$teil = 'liste' === ($_GET['teil'] ?? null);
|
||||
$this->zeigen($teil ? '_liste' : 'uebersicht', [
|
||||
'titel' => $tresor ? 'KantTresor' : 'Installationen',
|
||||
'produkt' => $produkt,
|
||||
'pfad' => $tresor ? '/kanttresor' : '/',
|
||||
'trefferText' => $trefferText,
|
||||
'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'])),
|
||||
],
|
||||
'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite),
|
||||
'treffer' => count($treffer),
|
||||
'suche' => $suche,
|
||||
'filter' => $gewaehlt,
|
||||
'seite' => $seite,
|
||||
'seiten' => $seiten,
|
||||
'zahlen' => $zahlen,
|
||||
// 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))),
|
||||
]);
|
||||
'aktualisierbar' => $tresor ? [] : array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))),
|
||||
], true, $teil);
|
||||
}
|
||||
|
||||
private function detail(Bestand $bestand, Fassungen $fassungen): void
|
||||
{
|
||||
$neueste = $fassungen->neueste();
|
||||
$eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null);
|
||||
$id = (string) ($_GET['id'] ?? '');
|
||||
$produkt = $bestand->produkt($id);
|
||||
$neueste = 'kanttresor' === $produkt ? $this->tresorNeueste($bestand) : $fassungen->neueste();
|
||||
$eine = $bestand->eine($id, $neueste['version'] ?? null);
|
||||
if (null === $eine) {
|
||||
$this->nichtGefunden();
|
||||
|
||||
return;
|
||||
}
|
||||
$this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste]);
|
||||
$this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste, 'produkt' => $eine['produkt']]);
|
||||
}
|
||||
|
||||
private function auftrag(Bestand $bestand, Fassungen $fassungen): void
|
||||
@@ -193,9 +268,10 @@ final class Anwendung
|
||||
|
||||
return;
|
||||
}
|
||||
$produkt = $bestand->produkt($id);
|
||||
$bestand->entfernen($id);
|
||||
$this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok');
|
||||
$this->weiter('/');
|
||||
$this->weiter('kanttresor' === $produkt ? '/kanttresor' : '/');
|
||||
}
|
||||
|
||||
private function fassungNeu(Fassungen $fassungen): void
|
||||
@@ -311,7 +387,7 @@ final class Anwendung
|
||||
|
||||
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("Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; 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');
|
||||
@@ -319,18 +395,24 @@ final class Anwendung
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void
|
||||
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void
|
||||
{
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''), (string) ($daten['pfad'] ?? '/'));
|
||||
if ($ohneRahmen) {
|
||||
echo $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
|
||||
|
||||
return;
|
||||
}
|
||||
$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,
|
||||
'produkt' => $daten['produkt'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+117
-36
@@ -11,6 +11,10 @@ namespace Zentrale;
|
||||
* 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.
|
||||
*
|
||||
* POST /api/kanttresor/melden nimmt auf dieselbe Weise die Meldung einer KantTresor-Instanz an
|
||||
* (ADR 124) - mit eigenen Angaben und nie mit einem Auftrag: KantTresor wird auf dem Server
|
||||
* mit ./update.sh aktualisiert.
|
||||
*/
|
||||
final class Api
|
||||
{
|
||||
@@ -36,36 +40,17 @@ final class Api
|
||||
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.');
|
||||
$anmeldung = $this->anmeldung($daten, $body, $signatur, 'kantsites');
|
||||
if (isset($anmeldung['status'])) {
|
||||
return $anmeldung;
|
||||
}
|
||||
['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung;
|
||||
|
||||
$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,
|
||||
'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)),
|
||||
'fassung' => $daten['fassung'],
|
||||
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
|
||||
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
||||
@@ -80,18 +65,7 @@ final class Api
|
||||
|
||||
$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']]);
|
||||
}
|
||||
$this->speichern($daten['id'], $schluessel, $installation, $werte);
|
||||
|
||||
$ergebnis = $daten['auftrag'] ?? null;
|
||||
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) {
|
||||
@@ -117,6 +91,113 @@ final class Api
|
||||
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldung einer KantTresor-Instanz: Stand, Mandantenzahl, gestörte Dienste, Speicher.
|
||||
* Die Antwort enthält nie einen Auftrag.
|
||||
*
|
||||
* @return array{status: int, body: string, signatur: ?string}
|
||||
*/
|
||||
public function meldenTresor(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);
|
||||
}
|
||||
$anmeldung = $this->anmeldung($daten, $body, $signatur, 'kanttresor');
|
||||
if (isset($anmeldung['status'])) {
|
||||
return $anmeldung;
|
||||
}
|
||||
['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung;
|
||||
|
||||
$stoerungen = is_array($daten['stoerungen'] ?? null) ? array_slice(array_values(array_filter($daten['stoerungen'], 'is_string')), 0, 10) : [];
|
||||
$mandanten = $daten['mandanten'] ?? null;
|
||||
$speicher = $daten['speicher'] ?? null;
|
||||
$werte = [
|
||||
'produkt' => 'kanttresor',
|
||||
'adresse' => $daten['adresse'],
|
||||
'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)),
|
||||
'fassung' => $daten['fassung'],
|
||||
'laufzeit' => mb_substr((string) ($daten['laufzeit'] ?? ''), 0, 60),
|
||||
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
||||
'mandanten' => is_int($mandanten) && $mandanten >= 0 && $mandanten <= 100000 ? $mandanten : null,
|
||||
'stoerungen' => mb_substr(implode(', ', array_map(static fn (string $s): string => mb_substr(trim($s), 0, 60), $stoerungen)), 0, 300),
|
||||
'speicher' => is_int($speicher) && $speicher >= 0 && $speicher <= 100 ? $speicher : null,
|
||||
'ip' => mb_substr($ip, 0, 45),
|
||||
'letzte_meldung' => Datenbank::jetzt(),
|
||||
];
|
||||
$this->speichern($daten['id'], $schluessel, $installation, $werte);
|
||||
|
||||
$antwort = '{"ok":true}';
|
||||
|
||||
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer meldet sich? Beim ersten Kontakt mit Schlüssel, danach nur mit gültiger Unterschrift.
|
||||
*
|
||||
* @param array<string, mixed> $daten
|
||||
*
|
||||
* @return array{installation: ?array<string, mixed>, schluessel: string}|array{status: int, body: string, signatur: null}
|
||||
*/
|
||||
private function anmeldung(array $daten, string $body, string $signatur, string $produkt): array
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
if (null !== $installation && $produkt !== $installation['produkt']) {
|
||||
return $this->fehler(403, 'Diese Kennung gehört zu einem anderen Produkt.');
|
||||
}
|
||||
|
||||
return ['installation' => $installation, 'schluessel' => $schluessel];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $installation
|
||||
* @param array<string, mixed> $werte mit letzte_meldung
|
||||
*/
|
||||
private function speichern(string $id, string $schluessel, ?array $installation, array $werte): void
|
||||
{
|
||||
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([$id, $schluessel, $werte['letzte_meldung'], ...array_values($werte)]);
|
||||
|
||||
return;
|
||||
}
|
||||
// Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation
|
||||
// kopiert oder umgezogen. Beides will man sehen.
|
||||
if ($installation['domain'] !== $werte['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), $id]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function pruefen(array $daten): ?string
|
||||
{
|
||||
|
||||
@@ -15,14 +15,33 @@ final class Bestand
|
||||
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
|
||||
public const AUFTRAG_STUNDEN = 2;
|
||||
|
||||
/**
|
||||
* Erste PHP-Fassung je Zweig ohne bekannte kritische Lücke (zuletzt php2shell,
|
||||
* CVE-2026-109618/-109619, 21.10.2026). Bei der nächsten PHP-Sicherheitsmeldung hier nachziehen.
|
||||
*/
|
||||
public const PHP_SICHER = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
|
||||
|
||||
/** Bis hierher gibt es keine Sicherheitsupdates mehr. */
|
||||
public const PHP_ALT_BIS = '8.1';
|
||||
|
||||
public function __construct(private \PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** Ab so viel Prozent belegtem Speicher gilt eine KantTresor-Instanz als knapp. */
|
||||
public const SPEICHER_KNAPP = 90;
|
||||
|
||||
/** Die Produkte, die sich melden, mit ihrer Übersicht. */
|
||||
public const PRODUKTE = ['kantsites' => 'KantSites', 'kanttresor' => 'KantTresor'];
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function alle(?string $neueste): array
|
||||
public function alle(?string $neueste, string $produkt = 'kantsites'): array
|
||||
{
|
||||
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
|
||||
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE produkt = ? ORDER BY domain, erste_meldung');
|
||||
$abfrage->execute([$produkt]);
|
||||
$zeilen = $abfrage->fetchAll();
|
||||
// Natürlich sortiert und ohne www: kunde-2 vor kunde-10, www.ebsolar.de bei e.
|
||||
usort($zeilen, static fn (array $a, array $b): int => strnatcasecmp(preg_replace('/^www\./', '', (string) $a['domain']), preg_replace('/^www\./', '', (string) $b['domain'])));
|
||||
$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;
|
||||
@@ -31,6 +50,34 @@ final class Bestand
|
||||
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen);
|
||||
}
|
||||
|
||||
/**
|
||||
* KantTresor hat keine öffentliche Releaseseite: Als neueste Fassung gilt die höchste, die
|
||||
* eine Instanz meldet. Wer dahinter liegt, ist veraltet.
|
||||
*/
|
||||
public function hoechsteFassung(string $produkt): ?string
|
||||
{
|
||||
$abfrage = $this->db->prepare("SELECT fassung FROM installation WHERE produkt = ? AND fassung <> 'unbekannt'");
|
||||
$abfrage->execute([$produkt]);
|
||||
$hoechste = null;
|
||||
foreach ($abfrage->fetchAll(\PDO::FETCH_COLUMN) as $fassung) {
|
||||
if (null === $hoechste || version_compare((string) $fassung, $hoechste, '>')) {
|
||||
$hoechste = (string) $fassung;
|
||||
}
|
||||
}
|
||||
|
||||
return $hoechste;
|
||||
}
|
||||
|
||||
/** Zu welchem Produkt gehört diese Kennung? */
|
||||
public function produkt(string $id): ?string
|
||||
{
|
||||
$abfrage = $this->db->prepare('SELECT produkt FROM installation WHERE id = ?');
|
||||
$abfrage->execute([$id]);
|
||||
$produkt = $abfrage->fetchColumn();
|
||||
|
||||
return false === $produkt ? null : (string) $produkt;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function eine(string $id, ?string $neueste): ?array
|
||||
{
|
||||
@@ -106,7 +153,10 @@ final class Bestand
|
||||
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
|
||||
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
|
||||
|
||||
$tresor = 'kanttresor' === $zeile['produkt'];
|
||||
|
||||
$grund = match (true) {
|
||||
$tresor => 'KantTresor wird auf dem Server mit ./update.sh aktualisiert, nicht von hier.',
|
||||
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.',
|
||||
@@ -124,9 +174,34 @@ final class Bestand
|
||||
'auftrag_zustand' => $zustand,
|
||||
'kann_update' => '' === $grund,
|
||||
'kein_update_grund' => $grund,
|
||||
'php_luecke' => $tresor ? null : self::phpLuecke((string) $zeile['php']),
|
||||
'speicher_knapp' => null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP,
|
||||
'gestoert' => '' !== (string) $zeile['stoerungen'] || (null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum die gemeldete PHP-Fassung als unsicher gilt, oder null. Unbekannte und neuere
|
||||
* Zweige (etwa 8.6-Vorabfassungen) gelten nicht als unsicher.
|
||||
*/
|
||||
public static function phpLuecke(string $php): ?string
|
||||
{
|
||||
if (1 !== preg_match('/^(\d+)\.(\d+)\.(\d+)/', $php, $teile)) {
|
||||
return null;
|
||||
}
|
||||
$zweig = $teile[1].'.'.$teile[2];
|
||||
$fassung = $zweig.'.'.$teile[3];
|
||||
if (version_compare($zweig, self::PHP_ALT_BIS, '<=')) {
|
||||
return sprintf('PHP %s bekommt keine Sicherheitsupdates mehr – auf PHP %s umstellen.', $zweig, self::PHP_SICHER['8.4']);
|
||||
}
|
||||
$sicher = self::PHP_SICHER[$zweig] ?? null;
|
||||
if (null !== $sicher && version_compare($fassung, $sicher, '<')) {
|
||||
return sprintf('PHP %s hat eine bekannte kritische Sicherheitslücke – mindestens %s nötig.', $fassung, $sicher);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $auftrag
|
||||
*
|
||||
|
||||
@@ -47,6 +47,15 @@ final class Datenbank
|
||||
)',
|
||||
'CREATE INDEX auftrag_installation ON auftrag (installation, id)',
|
||||
],
|
||||
// KantTresor meldet sich ebenfalls (ADR 124). Bestehende Zeilen sind KantSites.
|
||||
2 => [
|
||||
'ALTER TABLE installation ADD COLUMN produkt TEXT NOT NULL DEFAULT \'kantsites\'',
|
||||
'ALTER TABLE installation ADD COLUMN laufzeit TEXT NOT NULL DEFAULT \'\'',
|
||||
'ALTER TABLE installation ADD COLUMN mandanten INTEGER',
|
||||
'ALTER TABLE installation ADD COLUMN stoerungen TEXT NOT NULL DEFAULT \'\'',
|
||||
'ALTER TABLE installation ADD COLUMN speicher INTEGER',
|
||||
'CREATE INDEX installation_produkt ON installation (produkt)',
|
||||
],
|
||||
];
|
||||
|
||||
public static function oeffnen(string $datei): \PDO
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div id="liste" tabindex="-1" data-treffer="<?= $v->e($trefferText) ?>">
|
||||
<?php $tresor = 'kanttresor' === $produkt; ?>
|
||||
<?php if (0 === $zahlen['alle']) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Noch keine Meldung</h2>
|
||||
<?php if ($tresor) { ?>
|
||||
<p>Sobald eine KantTresor-Instanz ab Fassung 1.82.0 läuft, meldet sie sich hier – alle 15 Minuten über den Hintergrunddienst (beat).</p>
|
||||
<?php } else { ?>
|
||||
<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>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<?php } elseif (0 === $treffer) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Nichts gefunden</h2>
|
||||
<p>Keine <?= $tresor ? 'Instanz' : 'Installation' ?> passt zu Suche und Filter. <a href="<?= $v->e($pfad) ?>">Alle anzeigen</a></p>
|
||||
</div>
|
||||
<?php } elseif ($tresor) { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<caption class="unsichtbar">Gemeldete KantTresor-Instanzen, Seite <?= $seite ?> von <?= $seiten ?></caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Mandanten</th><th scope="col">Speicher</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</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></td>
|
||||
<td data-label="Mandanten"><?= $v->e($i['mandanten'] ?? '–') ?></td>
|
||||
<td data-label="Speicher"><?php if (null === $i['speicher']) { ?>–<?php } else { ?><span class="<?= $i['speicher_knapp'] ? 'knapp' : '' ?>"><?= (int) $i['speicher'] ?> % belegt</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 ('' !== (string) $i['stoerungen']) { ?><span class="pille p-fehler">Gestört: <?= $v->e($i['stoerungen']) ?></span> <?php } ?>
|
||||
<?php if ($i['speicher_knapp']) { ?><span class="pille p-warn">Speicher knapp</span> <?php } ?>
|
||||
<?php if (!$i['gestoert']) { ?><span class="pille p-ok">Alle Dienste laufen</span><?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<caption class="unsichtbar">Gemeldete Installationen, Seite <?= $seite ?> von <?= $seiten ?></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 (null !== $i['php_luecke']) { ?><span class="pille p-fehler" title="<?= $v->e($i['php_luecke']) ?>">PHP <?= $v->e($i['php']) ?> unsicher</span> <?php } ?>
|
||||
<?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'] && null === $i['php_luecke']) { ?><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 } ?>
|
||||
<?php if ($treffer > 0 && $seiten > 1) { ?>
|
||||
<nav class="seiten" aria-label="Seiten">
|
||||
<?php $basis = ['q' => $suche, 'filter' => $filter]; ?>
|
||||
<?php if ($seite > 1) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite - 1])) ?>" rel="prev">← Zurück</a><?php } ?>
|
||||
<ul>
|
||||
<?php
|
||||
// Erste, letzte und zwei um die aktuelle; dazwischen Auslassungen.
|
||||
$zeigen = array_unique(array_filter([1, $seite - 2, $seite - 1, $seite, $seite + 1, $seite + 2, $seiten], static fn (int $n): bool => $n >= 1 && $n <= $seiten));
|
||||
sort($zeigen);
|
||||
$vorher = 0;
|
||||
foreach ($zeigen as $n) {
|
||||
if ($n > $vorher + 1) { ?><li aria-hidden="true">…</li><?php }
|
||||
$vorher = $n; ?>
|
||||
<li><?php if ($n === $seite) { ?><span aria-current="page"><span class="unsichtbar">Seite </span><?= $n ?></span><?php } else { ?><a href="<?= $v->e($v->adresse([...$basis, 'seite' => $n])) ?>"><span class="unsichtbar">Seite </span><?= $n ?></a><?php } ?></li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
<?php if ($seite < $seiten) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite + 1])) ?>" rel="next">Weiter →</a><?php } ?>
|
||||
</nav>
|
||||
<?php } ?>
|
||||
</div>
|
||||
@@ -1,11 +1,37 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<p class="zurueck"><a href="/">← Alle Installationen</a></p>
|
||||
<?php $tresor = 'kanttresor' === $i['produkt']; ?>
|
||||
<p class="zurueck"><?php if ($tresor) { ?><a href="/kanttresor">← Alle KantTresor-Instanzen</a><?php } else { ?><a href="/">← Alle Installationen</a><?php } ?></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">
|
||||
<?php if ($tresor) { ?>
|
||||
<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 ? ' – höchste gemeldete ist '.$v->e($neueste['version']) : '' ?></dd>
|
||||
<dt>Laufzeit</dt><dd><?= $v->e($i['laufzeit'] ?: '–') ?></dd>
|
||||
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
|
||||
<dt>Mandanten</dt><dd><?= $v->e($i['mandanten'] ?? '–') ?></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>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="karte" aria-labelledby="t-betrieb">
|
||||
<h2 id="t-betrieb">Betrieb</h2>
|
||||
<dl class="fakten">
|
||||
<dt>Dienste</dt><dd><?php if ('' !== (string) $i['stoerungen']) { ?><span class="pille p-fehler">Gestört: <?= $v->e($i['stoerungen']) ?></span><?php } else { ?><span class="pille p-ok">Alle Dienste laufen</span><?php } ?></dd>
|
||||
<dt>Speicher</dt><dd><?php if (null === $i['speicher']) { ?>–<?php } else { ?><span class="<?= $i['speicher_knapp'] ? 'knapp' : '' ?>"><?= (int) $i['speicher'] ?> % belegt</span><?= $i['speicher_knapp'] ? ' – <strong>knapp</strong>' : '' ?><?php } ?></dd>
|
||||
</dl>
|
||||
<p class="klein abstand">Stand der letzten Meldung. Aktualisiert wird KantTresor auf dem Server mit <code>./update.sh</code>, nicht von hier.</p>
|
||||
</section>
|
||||
<?php } else { ?>
|
||||
<section class="karte" aria-labelledby="t-stand">
|
||||
<h2 id="t-stand">Stand</h2>
|
||||
<dl class="fakten">
|
||||
@@ -13,7 +39,8 @@
|
||||
<?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>PHP</dt><dd><?php if (null !== $i['php_luecke']) { ?><span class="pille p-fehler"><?= $v->e($i['php']) ?> unsicher</span>
|
||||
<span class="klein block"><?= $v->e($i['php_luecke']) ?> Das Update macht der Hoster bzw. das Hosting-Panel, nicht KantSites.</span><?php } else { ?><?= $v->e($i['php'] ?: '–') ?><?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>
|
||||
@@ -51,6 +78,7 @@
|
||||
</form>
|
||||
<?php } ?>
|
||||
</section>
|
||||
<?php } ?>
|
||||
|
||||
<section class="karte" aria-labelledby="t-kunde">
|
||||
<h2 id="t-kunde">Kunde</h2>
|
||||
@@ -65,6 +93,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php if (!$tresor) { ?>
|
||||
<section class="karte" aria-labelledby="t-verlauf">
|
||||
<h2 id="t-verlauf">Aufträge</h2>
|
||||
<?php if ([] === $i['verlauf']) { ?>
|
||||
@@ -87,6 +116,7 @@
|
||||
</div>
|
||||
<?php } ?>
|
||||
</section>
|
||||
<?php } ?>
|
||||
|
||||
<details class="karte gefahr">
|
||||
<summary>Eintrag entfernen</summary>
|
||||
|
||||
@@ -6,7 +6,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="stylesheet" href="/app.css?v=4">
|
||||
<script src="/app.js?v=2" defer></script>
|
||||
<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>
|
||||
@@ -23,6 +24,15 @@
|
||||
<?php } ?>
|
||||
</div>
|
||||
</header>
|
||||
<?php if (null !== $konto) { ?>
|
||||
<nav class="reiter" aria-label="Produkte">
|
||||
<ul class="reiter-innen">
|
||||
<?php foreach (['kantsites' => ['/', 'KantSites'], 'kanttresor' => ['/kanttresor', 'KantTresor']] as $name => [$ziel, $text]) { ?>
|
||||
<li><a href="<?= $ziel ?>"<?= $produkt === $name ? ' aria-current="page"' : '' ?>><?= $text ?></a></li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php } ?>
|
||||
<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>
|
||||
|
||||
@@ -1,69 +1,64 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<?php $tresor = 'kanttresor' === $produkt; ?>
|
||||
<div class="titelzeile">
|
||||
<h1>Installationen</h1>
|
||||
<h1><?= $tresor ? 'KantTresor-Instanzen' : 'Installationen' ?></h1>
|
||||
<?php if ($tresor) { ?>
|
||||
<p class="neueste">Höchste gemeldete Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></p>
|
||||
<?php } else { ?>
|
||||
<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>
|
||||
<?php } ?>
|
||||
</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
|
||||
$kacheln = [
|
||||
null => ['', 'gesamt', $zahlen['alle']],
|
||||
'aktuell' => ['z-ok', 'aktuell', $zahlen['aktuell'] ?? '–'],
|
||||
'veraltet' => ['z-warn', 'veraltet', $zahlen['veraltet']],
|
||||
'still' => ['z-still', 'ohne Meldung seit 48 h', $zahlen['still']],
|
||||
'unbekannt' => ['z-fremd', 'ohne Kundenzuordnung', $zahlen['unbekannt']],
|
||||
];
|
||||
if ($tresor) {
|
||||
$kacheln['gestoert'] = ['z-fremd', 'Dienst gestört oder Speicher knapp', $zahlen['gestoert']];
|
||||
} else {
|
||||
$kacheln['php'] = ['z-fremd', 'PHP mit Sicherheitslücke', $zahlen['php']];
|
||||
}
|
||||
?>
|
||||
<nav aria-label="Nach Zustand filtern">
|
||||
<ul class="zahlen">
|
||||
<?php foreach ($kacheln as $name => [$klasse, $text, $zahl]) { $aktiv = ($filter ?? '') === $name; ?>
|
||||
<li class="<?= $klasse ?><?= $aktiv ? ' gewaehlt' : '' ?>"><a href="<?= $v->e($v->adresse(['q' => $suche, 'filter' => '' === $name ? null : $name])) ?>"<?= $aktiv ? ' aria-current="true"' : '' ?>><strong><?= $v->e($zahl) ?></strong> <?= $v->e($text) ?></a></li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<?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>
|
||||
<?php $namen = array_column($aktualisierbar, 'domain'); ?>
|
||||
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_slice($namen, 0, 8))) ?><?= count($namen) > 8 ? ' und '.(count($namen) - 8).' weitere' : '' ?>. 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>
|
||||
<?php if ($zahlen['alle'] > 0) { ?>
|
||||
<form method="get" action="<?= $v->e($pfad) ?>" class="suche" role="search">
|
||||
<?php if (null !== $filter) { ?><input type="hidden" name="filter" value="<?= $v->e($filter) ?>"><?php } ?>
|
||||
<label for="q">Suchen</label>
|
||||
<div class="suche-zeile">
|
||||
<input id="q" name="q" type="search" value="<?= $v->e($suche) ?>" placeholder="<?= $tresor ? 'Domain, Kunde, Fassung, Störung oder IP' : 'Domain, Kunde, Fassung, PHP oder IP' ?>" autocomplete="off" aria-describedby="treffer" aria-controls="liste">
|
||||
<button type="submit" class="knopf">Suchen</button>
|
||||
<a class="knopf knopf-leise zuruecksetzen" href="<?= $v->e($pfad) ?>"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
|
||||
</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>
|
||||
<p class="klein" id="treffer" role="status"><?= $v->e($trefferText) ?></p>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php require __DIR__.'/_liste.php'; ?>
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
@@ -72,6 +74,9 @@ code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font
|
||||
.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; }
|
||||
.zahlen a { display: block; margin: -10px -14px; padding: 10px 14px; color: inherit; text-decoration: none; border-radius: inherit; }
|
||||
.zahlen a:hover { background: var(--marke-hell); }
|
||||
.zahlen li.gewaehlt { border-color: var(--marke); box-shadow: inset 0 0 0 1px var(--marke); }
|
||||
.z-ok strong { color: var(--ok); }
|
||||
.z-warn strong { color: var(--warn); }
|
||||
.z-still strong { color: var(--still); }
|
||||
@@ -143,6 +148,22 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
|
||||
|
||||
.leer p { color: var(--leise); margin: 0; }
|
||||
|
||||
.suche { margin-bottom: 14px; }
|
||||
.suche label { margin-top: 0; }
|
||||
.suche-zeile { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.suche-zeile input { flex: 1 1 260px; width: auto; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text); }
|
||||
.suche-zeile .knopf-leise { min-height: 40px; text-decoration: none; }
|
||||
.suche .klein { margin: 8px 0 0; }
|
||||
|
||||
.seiten { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
||||
.seiten .knopf-leise { text-decoration: none; }
|
||||
.seiten ul { list-style: none; display: flex; gap: 4px; margin: 0; padding: 0; flex-wrap: wrap; }
|
||||
.seiten li a, .seiten li span[aria-current] { display: inline-grid; place-items: center; min-width: 36px; min-height: 36px; padding: 0 8px; border-radius: 8px; text-decoration: none; }
|
||||
.seiten li a { color: var(--marke); border: 1px solid var(--linie); background: var(--flaeche); }
|
||||
.seiten li a:hover { background: var(--marke-hell); }
|
||||
.seiten li span[aria-current] { background: var(--marke); color: #fff; font-weight: 700; }
|
||||
.seiten li[aria-hidden] { align-self: center; color: var(--leise); padding: 0 2px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
h1 { font-size: 1.35rem; }
|
||||
.karte { padding: 16px; }
|
||||
@@ -157,3 +178,13 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
|
||||
.fakten { grid-template-columns: 1fr; gap: 0; }
|
||||
.fakten dd { margin-bottom: 8px; }
|
||||
}
|
||||
|
||||
#liste:focus { outline: none; }
|
||||
#liste[aria-busy="true"] { opacity: .6; transition: opacity .15s; }
|
||||
|
||||
.reiter { background: var(--flaeche); border-bottom: 1px solid var(--linie); }
|
||||
.reiter-innen { max-width: 1180px; margin: 0 auto; padding: 0 16px; list-style: none; display: flex; gap: 4px; overflow-x: auto; }
|
||||
.reiter a { display: block; padding: 12px 14px 10px; color: var(--leise); font-weight: 600; text-decoration: none; border-bottom: 3px solid transparent; white-space: nowrap; }
|
||||
.reiter a:hover { color: var(--marke); }
|
||||
.reiter a[aria-current="page"] { color: var(--marke); border-bottom-color: var(--marke); }
|
||||
.knapp { color: var(--warn); font-weight: 600; }
|
||||
@@ -0,0 +1,118 @@
|
||||
// Live-Suche der Übersicht: Tippen, Filter und Seitenwechsel laden nur die Liste nach.
|
||||
// Ohne JavaScript bleibt alles ein gewöhnliches Formular mit Links.
|
||||
(() => {
|
||||
const form = document.querySelector('form.suche');
|
||||
const liste = document.getElementById('liste');
|
||||
if (!form || !liste || !window.fetch || !window.URL) {
|
||||
return;
|
||||
}
|
||||
const feld = form.querySelector('#q');
|
||||
const status = document.getElementById('treffer');
|
||||
const zuruecksetzen = form.querySelector('.zuruecksetzen');
|
||||
const knopf = form.querySelector('button[type="submit"]');
|
||||
const filterFeld = () => form.querySelector('input[name="filter"]');
|
||||
knopf.hidden = true;
|
||||
|
||||
let warten = 0;
|
||||
let laufend = null;
|
||||
|
||||
/** Die Adresse aus Suchfeld und Filter, ohne leere Werte. */
|
||||
const adresseAusFormular = () => {
|
||||
const url = new URL(form.action, location.href);
|
||||
const q = feld.value.trim();
|
||||
if (q !== '') url.searchParams.set('q', q);
|
||||
const filter = filterFeld();
|
||||
if (filter && filter.value !== '') url.searchParams.set('filter', filter.value);
|
||||
return url;
|
||||
};
|
||||
|
||||
/** Kacheln und Filterfeld an Suche und Filter der neuen Adresse angleichen. */
|
||||
const umgebungAnpassen = (url) => {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const filter = url.searchParams.get('filter') || '';
|
||||
document.querySelectorAll('.zahlen a').forEach((a) => {
|
||||
const ziel = new URL(a.href, location.href);
|
||||
if (q === '') ziel.searchParams.delete('q'); else ziel.searchParams.set('q', q);
|
||||
ziel.searchParams.delete('seite');
|
||||
a.href = ziel.pathname + ziel.search;
|
||||
const gewaehlt = (ziel.searchParams.get('filter') || '') === filter;
|
||||
a.parentElement.classList.toggle('gewaehlt', gewaehlt);
|
||||
if (gewaehlt) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
|
||||
});
|
||||
let feldFilter = filterFeld();
|
||||
if (filter !== '' && !feldFilter) {
|
||||
feldFilter = document.createElement('input');
|
||||
feldFilter.type = 'hidden';
|
||||
feldFilter.name = 'filter';
|
||||
form.prepend(feldFilter);
|
||||
}
|
||||
if (feldFilter) {
|
||||
if (filter === '') feldFilter.remove(); else feldFilter.value = filter;
|
||||
}
|
||||
if (feld.value.trim() !== q) feld.value = q;
|
||||
zuruecksetzen.hidden = q === '' && filter === '';
|
||||
};
|
||||
|
||||
const laden = (url, verlauf) => {
|
||||
if (laufend) laufend.abort();
|
||||
laufend = new AbortController();
|
||||
const abruf = new URL(url, location.href);
|
||||
abruf.searchParams.set('teil', 'liste');
|
||||
liste.setAttribute('aria-busy', 'true');
|
||||
fetch(abruf, { signal: laufend.signal, credentials: 'same-origin', headers: { Accept: 'text/html' } })
|
||||
.then((antwort) => {
|
||||
// Abgelaufene Sitzung: Die Antwort ist die Anmeldeseite, nicht die Liste.
|
||||
if (!antwort.ok || antwort.redirected) throw new Error('voll');
|
||||
return antwort.text();
|
||||
})
|
||||
.then((html) => {
|
||||
const neu = new DOMParser().parseFromString(html, 'text/html').getElementById('liste');
|
||||
if (!neu) throw new Error('voll');
|
||||
liste.replaceChildren(...neu.childNodes);
|
||||
liste.dataset.treffer = neu.dataset.treffer || '';
|
||||
status.textContent = liste.dataset.treffer;
|
||||
const sichtbar = new URL(url, location.href);
|
||||
if (verlauf === 'push') history.pushState(null, '', sichtbar.pathname + sichtbar.search);
|
||||
else if (verlauf === 'replace') history.replaceState(null, '', sichtbar.pathname + sichtbar.search);
|
||||
umgebungAnpassen(sichtbar);
|
||||
})
|
||||
.catch((fehler) => {
|
||||
if (fehler.name !== 'AbortError') location.href = url.toString();
|
||||
})
|
||||
.finally(() => liste.removeAttribute('aria-busy'));
|
||||
};
|
||||
|
||||
feld.addEventListener('input', () => {
|
||||
clearTimeout(warten);
|
||||
warten = setTimeout(() => laden(adresseAusFormular(), 'replace'), 250);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
clearTimeout(warten);
|
||||
laden(adresseAusFormular(), 'replace');
|
||||
});
|
||||
|
||||
zuruecksetzen.addEventListener('click', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
feld.value = '';
|
||||
const filter = filterFeld();
|
||||
if (filter) filter.remove();
|
||||
laden(new URL(form.action, location.href), 'push');
|
||||
feld.focus();
|
||||
});
|
||||
|
||||
// Filterkacheln und Seitenzahlen: nur die Liste wechseln.
|
||||
document.addEventListener('click', (ereignis) => {
|
||||
const link = ereignis.target.closest('.zahlen a, #liste .seiten a');
|
||||
if (!link || ereignis.ctrlKey || ereignis.metaKey || ereignis.shiftKey || ereignis.button !== 0) return;
|
||||
ereignis.preventDefault();
|
||||
laden(new URL(link.href, location.href), 'push');
|
||||
if (link.closest('.seiten')) {
|
||||
form.scrollIntoView({ block: 'start' });
|
||||
liste.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => laden(new URL(location.href), null));
|
||||
})();
|
||||
Reference in new issue
Block a user