Files
KantSites/zentrale/app/Anwendung.php
T
kantarciandClaude Opus 5.5 968d19b611
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Add a KantTresor tab to the central registry
KantTresor instances (from 1.82.0) report to /api/kanttresor/melden with the
same key-then-HMAC protocol. The new tab lists them with tenants, disk usage
and failing services; it never offers or accepts an update order, since
KantTresor is updated with ./update.sh on the server. See ADR 124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:02:07 +01:00

419 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die Weiche: Adresse und Methode zu einer Aktion. Kein Framework - die Plattform hat sieben
* Seiten und eine Schnittstelle, und sie soll sich per FTP auf jedes Hosting mit PHP legen lassen.
*/
final class Anwendung
{
/** Zeilen je Seite in der Übersicht. */
private const PRO_SEITE = 25;
private \PDO $db;
/** @param array<string, mixed> $config */
public function __construct(private array $config, private string $wurzel)
{
}
public function bearbeiten(): void
{
$pfad = '/'.trim((string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH), '/');
$methode = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
$this->kopfzeilen();
try {
$this->db = Datenbank::oeffnen($this->wurzel.'/var/zentrale.db');
} catch (\Throwable $fehler) {
http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
echo "Die Datenbank lässt sich nicht öffnen. Ist var/ beschreibbar?\n";
error_log('Zentrale: '.$fehler->getMessage());
return;
}
if ('/api/melden' === $pfad || '/api/kanttresor/melden' === $pfad) {
$this->api($methode, '/api/melden' === $pfad ? 'kantsites' : 'kanttresor');
return;
}
if ('/robots.txt' === $pfad) {
header('Content-Type: text/plain; charset=UTF-8');
echo "User-agent: *\nDisallow: /\n";
return;
}
$this->sitzung();
$konten = new Konten($this->db);
if (!$konten->gibtEs()) {
$this->einrichten($konten, $methode);
return;
}
if ('/anmelden' === $pfad) {
$this->anmelden($konten, $methode);
return;
}
if (!isset($_SESSION['konto'])) {
$this->weiter('/anmelden');
return;
}
if ('POST' === $methode && !$this->csrfGueltig()) {
$this->hinweis('Die Sitzung ist abgelaufen. Bitte noch einmal.', 'fehler');
$this->weiter('/' === $pfad ? '/' : $pfad);
return;
}
$fassungen = new Fassungen((string) ($this->config['releases_api'] ?? ''), $this->wurzel.'/var/neueste.json');
$bestand = new Bestand($this->db);
match (true) {
'/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen->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),
'/auftrag/zurueckziehen' === $pfad && 'POST' === $methode => $this->zurueckziehen($bestand),
'/notiz' === $pfad && 'POST' === $methode => $this->notiz($bestand),
'/entfernen' === $pfad && 'POST' === $methode => $this->entfernen($bestand),
'/fassung' === $pfad && 'POST' === $methode => $this->fassungNeu($fassungen),
'/abmelden' === $pfad && 'POST' === $methode => $this->abmelden(),
default => $this->nichtGefunden(),
};
}
private function api(string $methode, string $produkt): void
{
header('Content-Type: application/json; charset=UTF-8');
if ('POST' !== $methode) {
http_response_code(405);
header('Allow: POST');
echo '{"ok":false,"fehler":"Nur POST."}';
return;
}
$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'] ?? ''),
);
http_response_code($antwort['status']);
if (null !== $antwort['signatur']) {
header('X-KantSites-Signatur: '.$antwort['signatur']);
}
echo $antwort['body'];
}
/**
* KantTresor hat keine öffentliche Releaseseite; die höchste gemeldete Fassung gilt als neueste.
*
* @return array{version: string}|null
*/
private function tresorNeueste(Bestand $bestand): ?array
{
$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' => 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' => $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
{
$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, 'produkt' => $eine['produkt']]);
}
private function auftrag(Bestand $bestand, Fassungen $fassungen): void
{
$id = (string) ($_POST['id'] ?? '');
$neueste = $fassungen->neueste()['version'] ?? null;
$grund = null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : $bestand->beauftragen($id, $neueste);
$this->hinweis(null === $grund ? sprintf('Update auf %s beauftragt. Die Website holt es mit ihrer nächsten Meldung ab (meist innerhalb von 15 Minuten).', $neueste) : $grund, null === $grund ? 'ok' : 'fehler');
$this->weiter('detail' === ($_POST['zurueck'] ?? '') ? '/installation?id='.rawurlencode($id) : '/');
}
private function alleAuftraege(Bestand $bestand, Fassungen $fassungen): void
{
$neueste = $fassungen->neueste()['version'] ?? null;
if ('ja' !== ($_POST['bestaetigt'] ?? '') || null === $neueste) {
$this->hinweis(null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : 'Bitte zuerst bestätigen, dass alle veralteten Installationen aktualisiert werden sollen.', 'fehler');
$this->weiter('/');
return;
}
$anzahl = 0;
foreach ($bestand->alle($neueste) as $installation) {
if ($installation['kann_update'] && !$installation['still'] && null === $bestand->beauftragen((string) $installation['id'], $neueste)) {
++$anzahl;
}
}
$this->hinweis(sprintf('%d Update(s) auf %s beauftragt.', $anzahl, $neueste), 'ok');
$this->weiter('/');
}
private function zurueckziehen(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
$this->hinweis($bestand->zurueckziehen($id) ? 'Auftrag zurückgezogen.' : 'Der Auftrag war schon abgeholt und lässt sich nicht mehr zurückziehen.', 'ok');
$this->weiter('/installation?id='.rawurlencode($id));
}
private function notiz(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
$bestand->notiz($id, (string) ($_POST['notiz'] ?? ''));
$this->hinweis('Gespeichert.', 'ok');
$this->weiter('/installation?id='.rawurlencode($id));
}
private function entfernen(Bestand $bestand): void
{
$id = (string) ($_POST['id'] ?? '');
if ('ja' !== ($_POST['bestaetigt'] ?? '')) {
$this->hinweis('Bitte bestätigen, dass der Eintrag entfernt werden soll.', 'fehler');
$this->weiter('/installation?id='.rawurlencode($id));
return;
}
$produkt = $bestand->produkt($id);
$bestand->entfernen($id);
$this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok');
$this->weiter('kanttresor' === $produkt ? '/kanttresor' : '/');
}
private function fassungNeu(Fassungen $fassungen): void
{
$neu = $fassungen->neueste(true);
$this->hinweis(null === $neu ? 'Die Releaseseite antwortet nicht.' : sprintf('Neueste Fassung: %s.', $neu['version']), null === $neu ? 'fehler' : 'ok');
$this->weiter('/');
}
private function einrichten(Konten $konten, string $methode): void
{
$code = (string) ($this->config['einrichtungscode'] ?? '');
$fehler = null;
if (strlen($code) < 16) {
$fehler = 'In config.php fehlt ein einrichtungscode mit mindestens 16 Zeichen. Erst danach lässt sich das erste Konto anlegen.';
} elseif ('POST' === $methode) {
$name = trim((string) ($_POST['name'] ?? ''));
$passwort = (string) ($_POST['passwort'] ?? '');
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$fehler = match (true) {
!$this->csrfGueltig() => 'Die Sitzung ist abgelaufen. Bitte noch einmal.',
$konten->gesperrt($ip) => 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.',
!hash_equals($code, (string) ($_POST['code'] ?? '')) => 'Der Einrichtungscode stimmt nicht.',
1 !== preg_match('/^[\p{L}\p{N}._@-]{3,60}$/uD', $name) => 'Der Name braucht 3 bis 60 Zeichen (Buchstaben, Ziffern, . _ @ -).',
mb_strlen($passwort) < 12 => 'Das Passwort braucht mindestens 12 Zeichen.',
$passwort !== (string) ($_POST['passwort2'] ?? '') => 'Die beiden Passwörter stimmen nicht überein.',
default => null,
};
if (null !== $fehler && str_starts_with($fehler, 'Der Einrichtungscode')) {
$konten->fehlversuch($ip);
}
if (null === $fehler) {
$konten->anlegen($name, $passwort);
$this->hinweis('Konto angelegt. Bitte jetzt anmelden.', 'ok');
$this->weiter('/anmelden');
return;
}
}
$this->zeigen('einrichten', ['titel' => 'Einrichten', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false);
}
private function anmelden(Konten $konten, string $methode): void
{
if (isset($_SESSION['konto'])) {
$this->weiter('/');
return;
}
$fehler = null;
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
if ('POST' === $methode) {
if (!$this->csrfGueltig()) {
$fehler = 'Die Sitzung ist abgelaufen. Bitte noch einmal.';
} elseif ($konten->gesperrt($ip)) {
$fehler = 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.';
} else {
$konto = $konten->pruefen(trim((string) ($_POST['name'] ?? '')), (string) ($_POST['passwort'] ?? ''), $ip);
if (null !== $konto) {
session_regenerate_id(true);
$_SESSION['konto'] = $konto;
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$this->weiter('/');
return;
}
$fehler = 'Name oder Passwort stimmen nicht.';
}
http_response_code(422);
}
$this->zeigen('anmelden', ['titel' => 'Anmelden', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false);
}
private function abmelden(): void
{
$_SESSION = [];
session_regenerate_id(true);
$this->weiter('/anmelden');
}
private function nichtGefunden(): void
{
http_response_code(404);
$this->zeigen('fehlt', ['titel' => 'Nicht gefunden'], isset($_SESSION['konto']));
}
private function sitzung(): void
{
$https = 'on' === strtolower((string) ($_SERVER['HTTPS'] ?? '')) || '443' === (string) ($_SERVER['SERVER_PORT'] ?? '');
session_name($https ? '__Host-zentrale' : 'zentrale');
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']);
ini_set('session.use_strict_mode', '1');
ini_set('session.gc_maxlifetime', '28800');
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
private function csrfGueltig(): bool
{
return is_string($_POST['_csrf'] ?? null) && hash_equals((string) ($_SESSION['csrf'] ?? ''), $_POST['_csrf']);
}
private function hinweis(string $text, string $art): void
{
$_SESSION['hinweis'] = ['text' => $text, 'art' => $art];
}
private function weiter(string $ziel): void
{
http_response_code(303);
header('Location: '.$ziel);
}
private function kopfzeilen(): void
{
header("Content-Security-Policy: default-src 'none'; 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');
header('Cache-Control: no-store');
}
/** @param array<string, mixed> $daten */
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']);
$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,
]);
}
}