Compare commits
8
Commits
02e4412771
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968d19b611 | ||
|
|
9d80af3795 | ||
|
|
d3ee8e3d1e | ||
|
|
6b99781819 | ||
|
|
6cfbc6cf7e | ||
|
|
bb736e8258 | ||
|
|
7cdb431fd4 | ||
|
|
821c799225 |
No files matched your search
Generated
+216
-217
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,14 @@
|
|||||||
# Architekturentscheidungen
|
# 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)
|
## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
|
||||||
|
|
||||||
|
|||||||
+51
-1
@@ -1,5 +1,53 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
**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 +58,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.
|
**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)
|
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
|
||||||
|
|
||||||
|
|||||||
@@ -105,5 +105,37 @@ $umzug->melden();
|
|||||||
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
|
$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');
|
$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";
|
echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
|
||||||
exit($fehler > 0 ? 1 : 0);
|
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
|
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
|
||||||
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
|
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
|
## Update aus der Ferne
|
||||||
|
|
||||||
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
|
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.
|
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“.
|
- 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
|
## Fremde Installationen erkennen
|
||||||
|
|
||||||
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
|
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ namespace Zentrale;
|
|||||||
*/
|
*/
|
||||||
final class Ansicht
|
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).'">';
|
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. */
|
/** Ein Zeitpunkt in deutscher Ortszeit. */
|
||||||
public function zeit(?string $iso): string
|
public function zeit(?string $iso): string
|
||||||
{
|
{
|
||||||
|
|||||||
+109
-27
@@ -10,6 +10,9 @@ namespace Zentrale;
|
|||||||
*/
|
*/
|
||||||
final class Anwendung
|
final class Anwendung
|
||||||
{
|
{
|
||||||
|
/** Zeilen je Seite in der Übersicht. */
|
||||||
|
private const PRO_SEITE = 25;
|
||||||
|
|
||||||
private \PDO $db;
|
private \PDO $db;
|
||||||
|
|
||||||
/** @param array<string, mixed> $config */
|
/** @param array<string, mixed> $config */
|
||||||
@@ -34,8 +37,8 @@ final class Anwendung
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('/api/melden' === $pfad) {
|
if ('/api/melden' === $pfad || '/api/kanttresor/melden' === $pfad) {
|
||||||
$this->api($methode);
|
$this->api($methode, '/api/melden' === $pfad ? 'kantsites' : 'kanttresor');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,8 @@ final class Anwendung
|
|||||||
$bestand = new Bestand($this->db);
|
$bestand = new Bestand($this->db);
|
||||||
|
|
||||||
match (true) {
|
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),
|
'/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen),
|
||||||
'/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen),
|
'/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen),
|
||||||
'/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($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');
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
if ('POST' !== $methode) {
|
if ('POST' !== $methode) {
|
||||||
@@ -97,7 +101,8 @@ final class Anwendung
|
|||||||
|
|
||||||
return;
|
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) file_get_contents('php://input', false, null, 0, 16385),
|
||||||
(string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''),
|
(string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''),
|
||||||
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||||
@@ -109,36 +114,106 @@ final class Anwendung
|
|||||||
echo $antwort['body'];
|
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();
|
$hoechste = $bestand->hoechsteFassung('kanttresor');
|
||||||
$alle = $bestand->alle($neueste['version'] ?? null);
|
|
||||||
$this->zeigen('uebersicht', [
|
return null === $hoechste ? null : ['version' => $hoechste];
|
||||||
'titel' => 'Installationen',
|
}
|
||||||
|
|
||||||
|
/** @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,
|
'neueste' => $neueste,
|
||||||
'installationen' => $alle,
|
'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite),
|
||||||
'zahlen' => [
|
'treffer' => count($treffer),
|
||||||
'alle' => count($alle),
|
'suche' => $suche,
|
||||||
'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])),
|
'filter' => $gewaehlt,
|
||||||
'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])),
|
'seite' => $seite,
|
||||||
'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])),
|
'seiten' => $seiten,
|
||||||
'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])),
|
'zahlen' => $zahlen,
|
||||||
],
|
|
||||||
// Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen.
|
// 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
|
private function detail(Bestand $bestand, Fassungen $fassungen): void
|
||||||
{
|
{
|
||||||
$neueste = $fassungen->neueste();
|
$id = (string) ($_GET['id'] ?? '');
|
||||||
$eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null);
|
$produkt = $bestand->produkt($id);
|
||||||
|
$neueste = 'kanttresor' === $produkt ? $this->tresorNeueste($bestand) : $fassungen->neueste();
|
||||||
|
$eine = $bestand->eine($id, $neueste['version'] ?? null);
|
||||||
if (null === $eine) {
|
if (null === $eine) {
|
||||||
$this->nichtGefunden();
|
$this->nichtGefunden();
|
||||||
|
|
||||||
return;
|
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
|
private function auftrag(Bestand $bestand, Fassungen $fassungen): void
|
||||||
@@ -193,9 +268,10 @@ final class Anwendung
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$produkt = $bestand->produkt($id);
|
||||||
$bestand->entfernen($id);
|
$bestand->entfernen($id);
|
||||||
$this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok');
|
$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
|
private function fassungNeu(Fassungen $fassungen): void
|
||||||
@@ -311,7 +387,7 @@ final class Anwendung
|
|||||||
|
|
||||||
private function kopfzeilen(): void
|
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('X-Content-Type-Options: nosniff');
|
||||||
header('Referrer-Policy: no-referrer');
|
header('Referrer-Policy: no-referrer');
|
||||||
header('X-Robots-Tag: noindex, nofollow');
|
header('X-Robots-Tag: noindex, nofollow');
|
||||||
@@ -319,18 +395,24 @@ final class Anwendung
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $daten */
|
/** @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');
|
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;
|
$hinweis = $_SESSION['hinweis'] ?? null;
|
||||||
unset($_SESSION['hinweis']);
|
unset($_SESSION['hinweis']);
|
||||||
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''));
|
|
||||||
$inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
|
$inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
|
||||||
echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [
|
echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [
|
||||||
'titel' => $daten['titel'] ?? 'Zentrale',
|
'titel' => $daten['titel'] ?? 'Zentrale',
|
||||||
'inhalt' => $inhalt,
|
'inhalt' => $inhalt,
|
||||||
'hinweis' => $hinweis,
|
'hinweis' => $hinweis,
|
||||||
'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null,
|
'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
|
* 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
|
* Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die
|
||||||
* Installation nur Aufträge von hier annimmt.
|
* 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
|
final class Api
|
||||||
{
|
{
|
||||||
@@ -36,36 +40,17 @@ final class Api
|
|||||||
if (is_string($pruef)) {
|
if (is_string($pruef)) {
|
||||||
return $this->fehler(400, $pruef);
|
return $this->fehler(400, $pruef);
|
||||||
}
|
}
|
||||||
$signatur = strtolower(trim($signatur));
|
$anmeldung = $this->anmeldung($daten, $body, $signatur, 'kantsites');
|
||||||
|
if (isset($anmeldung['status'])) {
|
||||||
$gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
|
return $anmeldung;
|
||||||
$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.');
|
|
||||||
}
|
}
|
||||||
|
['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung;
|
||||||
|
|
||||||
$jetzt = Datenbank::jetzt();
|
$jetzt = Datenbank::jetzt();
|
||||||
$domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST));
|
|
||||||
$update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
|
$update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
|
||||||
$werte = [
|
$werte = [
|
||||||
'adresse' => $daten['adresse'],
|
'adresse' => $daten['adresse'],
|
||||||
'domain' => $domain,
|
'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)),
|
||||||
'fassung' => $daten['fassung'],
|
'fassung' => $daten['fassung'],
|
||||||
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
|
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
|
||||||
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
||||||
@@ -80,18 +65,7 @@ final class Api
|
|||||||
|
|
||||||
$this->db->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
if (null === $installation) {
|
$this->speichern($daten['id'], $schluessel, $installation, $werte);
|
||||||
$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;
|
$ergebnis = $daten['auftrag'] ?? null;
|
||||||
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? 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)];
|
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 */
|
/** @param array<string, mixed> $daten */
|
||||||
private function pruefen(array $daten): ?string
|
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. */
|
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
|
||||||
public const AUFTRAG_STUNDEN = 2;
|
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)
|
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>> */
|
/** @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 = [];
|
$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) {
|
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;
|
$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);
|
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 */
|
/** @return array<string, mixed>|null */
|
||||||
public function eine(string $id, ?string $neueste): ?array
|
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.
|
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
|
||||||
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
|
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
|
||||||
|
|
||||||
|
$tresor = 'kanttresor' === $zeile['produkt'];
|
||||||
|
|
||||||
$grund = match (true) {
|
$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.',
|
null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
|
||||||
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
|
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
|
||||||
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
|
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
|
||||||
@@ -124,9 +174,34 @@ final class Bestand
|
|||||||
'auftrag_zustand' => $zustand,
|
'auftrag_zustand' => $zustand,
|
||||||
'kann_update' => '' === $grund,
|
'kann_update' => '' === $grund,
|
||||||
'kein_update_grund' => $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
|
* @param array<string, mixed> $auftrag
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ final class Datenbank
|
|||||||
)',
|
)',
|
||||||
'CREATE INDEX auftrag_installation ON auftrag (installation, id)',
|
'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
|
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 */ ?>
|
<?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">
|
<div class="titelzeile">
|
||||||
<h1><?= $v->e($i['domain']) ?></h1>
|
<h1><?= $v->e($i['domain']) ?></h1>
|
||||||
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
|
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="raster">
|
<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">
|
<section class="karte" aria-labelledby="t-stand">
|
||||||
<h2 id="t-stand">Stand</h2>
|
<h2 id="t-stand">Stand</h2>
|
||||||
<dl class="fakten">
|
<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 } ?>
|
<?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>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>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>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
|
||||||
<dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></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>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>
|
</form>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
</section>
|
</section>
|
||||||
|
<?php } ?>
|
||||||
|
|
||||||
<section class="karte" aria-labelledby="t-kunde">
|
<section class="karte" aria-labelledby="t-kunde">
|
||||||
<h2 id="t-kunde">Kunde</h2>
|
<h2 id="t-kunde">Kunde</h2>
|
||||||
@@ -65,6 +93,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$tresor) { ?>
|
||||||
<section class="karte" aria-labelledby="t-verlauf">
|
<section class="karte" aria-labelledby="t-verlauf">
|
||||||
<h2 id="t-verlauf">Aufträge</h2>
|
<h2 id="t-verlauf">Aufträge</h2>
|
||||||
<?php if ([] === $i['verlauf']) { ?>
|
<?php if ([] === $i['verlauf']) { ?>
|
||||||
@@ -87,6 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
</section>
|
</section>
|
||||||
|
<?php } ?>
|
||||||
|
|
||||||
<details class="karte gefahr">
|
<details class="karte gefahr">
|
||||||
<summary>Eintrag entfernen</summary>
|
<summary>Eintrag entfernen</summary>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
|
<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">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -23,6 +24,15 @@
|
|||||||
<?php } ?>
|
<?php } ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<main id="inhalt" class="seite" tabindex="-1">
|
||||||
<?php if (null !== $hinweis) { ?>
|
<?php if (null !== $hinweis) { ?>
|
||||||
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
|
<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 /** @var Zentrale\Ansicht $v */ ?>
|
||||||
|
<?php $tresor = 'kanttresor' === $produkt; ?>
|
||||||
<div class="titelzeile">
|
<div class="titelzeile">
|
||||||
<h1>Installationen</h1>
|
<h1><?= $tresor ? 'KantTresor-Instanzen' : 'Installationen' ?></h1>
|
||||||
<form method="post" action="/fassung" class="neueste">
|
<?php if ($tresor) { ?>
|
||||||
<?= $v->csrf() ?>
|
<p class="neueste">Höchste gemeldete Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></p>
|
||||||
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span>
|
<?php } else { ?>
|
||||||
<button type="submit" class="knopf knopf-leise">Neu abfragen</button>
|
<form method="post" action="/fassung" class="neueste">
|
||||||
</form>
|
<?= $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>
|
</div>
|
||||||
|
|
||||||
<ul class="zahlen" aria-label="Zusammenfassung">
|
<?php
|
||||||
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li>
|
$kacheln = [
|
||||||
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li>
|
null => ['', 'gesamt', $zahlen['alle']],
|
||||||
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li>
|
'aktuell' => ['z-ok', 'aktuell', $zahlen['aktuell'] ?? '–'],
|
||||||
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li>
|
'veraltet' => ['z-warn', 'veraltet', $zahlen['veraltet']],
|
||||||
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li>
|
'still' => ['z-still', 'ohne Meldung seit 48 h', $zahlen['still']],
|
||||||
</ul>
|
'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) { ?>
|
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
|
||||||
<form method="post" action="/auftrag/alle" class="karte alle">
|
<form method="post" action="/auftrag/alle" class="karte alle">
|
||||||
<?= $v->csrf() ?>
|
<?= $v->csrf() ?>
|
||||||
<div>
|
<div>
|
||||||
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
|
<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>
|
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="knopf">Alle aktualisieren</button>
|
<button type="submit" class="knopf">Alle aktualisieren</button>
|
||||||
</form>
|
</form>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
|
||||||
<?php if ([] === $installationen) { ?>
|
<?php if ($zahlen['alle'] > 0) { ?>
|
||||||
<div class="karte leer">
|
<form method="get" action="<?= $v->e($pfad) ?>" class="suche" role="search">
|
||||||
<h2>Noch keine Meldung</h2>
|
<?php if (null !== $filter) { ?><input type="hidden" name="filter" value="<?= $v->e($filter) ?>"><?php } ?>
|
||||||
<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>
|
<label for="q">Suchen</label>
|
||||||
</div>
|
<div class="suche-zeile">
|
||||||
<?php } else { ?>
|
<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">
|
||||||
<div class="tabelle-rahmen">
|
<button type="submit" class="knopf">Suchen</button>
|
||||||
<table class="tabelle">
|
<a class="knopf knopf-leise zuruecksetzen" href="<?= $v->e($pfad) ?>"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
|
||||||
<caption class="unsichtbar">Alle gemeldeten Installationen</caption>
|
</div>
|
||||||
<thead>
|
<p class="klein" id="treffer" role="status"><?= $v->e($trefferText) ?></p>
|
||||||
<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>
|
</form>
|
||||||
</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 } ?>
|
<?php } ?>
|
||||||
|
|
||||||
|
<?php require __DIR__.'/_liste.php'; ?>
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
|
||||||
body {
|
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 { 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 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 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-ok strong { color: var(--ok); }
|
||||||
.z-warn strong { color: var(--warn); }
|
.z-warn strong { color: var(--warn); }
|
||||||
.z-still strong { color: var(--still); }
|
.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; }
|
.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) {
|
@media (max-width: 760px) {
|
||||||
h1 { font-size: 1.35rem; }
|
h1 { font-size: 1.35rem; }
|
||||||
.karte { padding: 16px; }
|
.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 { grid-template-columns: 1fr; gap: 0; }
|
||||||
.fakten dd { margin-bottom: 8px; }
|
.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