$config */ public function __construct(private array $config, private string $wurzel) { } public function bearbeiten(): void { $pfad = '/'.trim((string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH), '/'); $methode = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'); $this->kopfzeilen(); try { $this->db = Datenbank::oeffnen($this->wurzel.'/var/zentrale.db'); } catch (\Throwable $fehler) { http_response_code(500); header('Content-Type: text/plain; charset=UTF-8'); echo "Die Datenbank lässt sich nicht öffnen. Ist var/ beschreibbar?\n"; error_log('Zentrale: '.$fehler->getMessage()); return; } if ('/api/melden' === $pfad) { $this->api($methode); return; } if ('/robots.txt' === $pfad) { header('Content-Type: text/plain; charset=UTF-8'); echo "User-agent: *\nDisallow: /\n"; return; } $this->sitzung(); $konten = new Konten($this->db); if (!$konten->gibtEs()) { $this->einrichten($konten, $methode); return; } if ('/anmelden' === $pfad) { $this->anmelden($konten, $methode); return; } if (!isset($_SESSION['konto'])) { $this->weiter('/anmelden'); return; } if ('POST' === $methode && !$this->csrfGueltig()) { $this->hinweis('Die Sitzung ist abgelaufen. Bitte noch einmal.', 'fehler'); $this->weiter('/' === $pfad ? '/' : $pfad); return; } $fassungen = new Fassungen((string) ($this->config['releases_api'] ?? ''), $this->wurzel.'/var/neueste.json'); $bestand = new Bestand($this->db); match (true) { '/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen), '/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen), '/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen), '/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen), '/auftrag/zurueckziehen' === $pfad && 'POST' === $methode => $this->zurueckziehen($bestand), '/notiz' === $pfad && 'POST' === $methode => $this->notiz($bestand), '/entfernen' === $pfad && 'POST' === $methode => $this->entfernen($bestand), '/fassung' === $pfad && 'POST' === $methode => $this->fassungNeu($fassungen), '/abmelden' === $pfad && 'POST' === $methode => $this->abmelden(), default => $this->nichtGefunden(), }; } private function api(string $methode): void { header('Content-Type: application/json; charset=UTF-8'); if ('POST' !== $methode) { http_response_code(405); header('Allow: POST'); echo '{"ok":false,"fehler":"Nur POST."}'; return; } $antwort = (new Api($this->db))->melden( (string) file_get_contents('php://input', false, null, 0, 16385), (string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''), (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ); http_response_code($antwort['status']); if (null !== $antwort['signatur']) { header('X-KantSites-Signatur: '.$antwort['signatur']); } echo $antwort['body']; } private function uebersicht(Bestand $bestand, Fassungen $fassungen): void { $neueste = $fassungen->neueste(); $alle = $bestand->alle($neueste['version'] ?? null); // 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'], ]; $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', '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']; $von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1; $bis = min(count($treffer), $seite * $proSeite); $trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), 1 === count($treffer) ? 'Installation' : 'Installationen') .(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' => 'Installationen', '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' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))), ], true, $teil); } private function detail(Bestand $bestand, Fassungen $fassungen): void { $neueste = $fassungen->neueste(); $eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null); if (null === $eine) { $this->nichtGefunden(); return; } $this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste]); } private function auftrag(Bestand $bestand, Fassungen $fassungen): void { $id = (string) ($_POST['id'] ?? ''); $neueste = $fassungen->neueste()['version'] ?? null; $grund = null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : $bestand->beauftragen($id, $neueste); $this->hinweis(null === $grund ? sprintf('Update auf %s beauftragt. Die Website holt es mit ihrer nächsten Meldung ab (meist innerhalb von 15 Minuten).', $neueste) : $grund, null === $grund ? 'ok' : 'fehler'); $this->weiter('detail' === ($_POST['zurueck'] ?? '') ? '/installation?id='.rawurlencode($id) : '/'); } private function alleAuftraege(Bestand $bestand, Fassungen $fassungen): void { $neueste = $fassungen->neueste()['version'] ?? null; if ('ja' !== ($_POST['bestaetigt'] ?? '') || null === $neueste) { $this->hinweis(null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : 'Bitte zuerst bestätigen, dass alle veralteten Installationen aktualisiert werden sollen.', 'fehler'); $this->weiter('/'); return; } $anzahl = 0; foreach ($bestand->alle($neueste) as $installation) { if ($installation['kann_update'] && !$installation['still'] && null === $bestand->beauftragen((string) $installation['id'], $neueste)) { ++$anzahl; } } $this->hinweis(sprintf('%d Update(s) auf %s beauftragt.', $anzahl, $neueste), 'ok'); $this->weiter('/'); } private function zurueckziehen(Bestand $bestand): void { $id = (string) ($_POST['id'] ?? ''); $this->hinweis($bestand->zurueckziehen($id) ? 'Auftrag zurückgezogen.' : 'Der Auftrag war schon abgeholt und lässt sich nicht mehr zurückziehen.', 'ok'); $this->weiter('/installation?id='.rawurlencode($id)); } private function notiz(Bestand $bestand): void { $id = (string) ($_POST['id'] ?? ''); $bestand->notiz($id, (string) ($_POST['notiz'] ?? '')); $this->hinweis('Gespeichert.', 'ok'); $this->weiter('/installation?id='.rawurlencode($id)); } private function entfernen(Bestand $bestand): void { $id = (string) ($_POST['id'] ?? ''); if ('ja' !== ($_POST['bestaetigt'] ?? '')) { $this->hinweis('Bitte bestätigen, dass der Eintrag entfernt werden soll.', 'fehler'); $this->weiter('/installation?id='.rawurlencode($id)); return; } $bestand->entfernen($id); $this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok'); $this->weiter('/'); } private function fassungNeu(Fassungen $fassungen): void { $neu = $fassungen->neueste(true); $this->hinweis(null === $neu ? 'Die Releaseseite antwortet nicht.' : sprintf('Neueste Fassung: %s.', $neu['version']), null === $neu ? 'fehler' : 'ok'); $this->weiter('/'); } private function einrichten(Konten $konten, string $methode): void { $code = (string) ($this->config['einrichtungscode'] ?? ''); $fehler = null; if (strlen($code) < 16) { $fehler = 'In config.php fehlt ein einrichtungscode mit mindestens 16 Zeichen. Erst danach lässt sich das erste Konto anlegen.'; } elseif ('POST' === $methode) { $name = trim((string) ($_POST['name'] ?? '')); $passwort = (string) ($_POST['passwort'] ?? ''); $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $fehler = match (true) { !$this->csrfGueltig() => 'Die Sitzung ist abgelaufen. Bitte noch einmal.', $konten->gesperrt($ip) => 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.', !hash_equals($code, (string) ($_POST['code'] ?? '')) => 'Der Einrichtungscode stimmt nicht.', 1 !== preg_match('/^[\p{L}\p{N}._@-]{3,60}$/uD', $name) => 'Der Name braucht 3 bis 60 Zeichen (Buchstaben, Ziffern, . _ @ -).', mb_strlen($passwort) < 12 => 'Das Passwort braucht mindestens 12 Zeichen.', $passwort !== (string) ($_POST['passwort2'] ?? '') => 'Die beiden Passwörter stimmen nicht überein.', default => null, }; if (null !== $fehler && str_starts_with($fehler, 'Der Einrichtungscode')) { $konten->fehlversuch($ip); } if (null === $fehler) { $konten->anlegen($name, $passwort); $this->hinweis('Konto angelegt. Bitte jetzt anmelden.', 'ok'); $this->weiter('/anmelden'); return; } } $this->zeigen('einrichten', ['titel' => 'Einrichten', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false); } private function anmelden(Konten $konten, string $methode): void { if (isset($_SESSION['konto'])) { $this->weiter('/'); return; } $fehler = null; $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); if ('POST' === $methode) { if (!$this->csrfGueltig()) { $fehler = 'Die Sitzung ist abgelaufen. Bitte noch einmal.'; } elseif ($konten->gesperrt($ip)) { $fehler = 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.'; } else { $konto = $konten->pruefen(trim((string) ($_POST['name'] ?? '')), (string) ($_POST['passwort'] ?? ''), $ip); if (null !== $konto) { session_regenerate_id(true); $_SESSION['konto'] = $konto; $_SESSION['csrf'] = bin2hex(random_bytes(32)); $this->weiter('/'); return; } $fehler = 'Name oder Passwort stimmen nicht.'; } http_response_code(422); } $this->zeigen('anmelden', ['titel' => 'Anmelden', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false); } private function abmelden(): void { $_SESSION = []; session_regenerate_id(true); $this->weiter('/anmelden'); } private function nichtGefunden(): void { http_response_code(404); $this->zeigen('fehlt', ['titel' => 'Nicht gefunden'], isset($_SESSION['konto'])); } private function sitzung(): void { $https = 'on' === strtolower((string) ($_SERVER['HTTPS'] ?? '')) || '443' === (string) ($_SERVER['SERVER_PORT'] ?? ''); session_name($https ? '__Host-zentrale' : 'zentrale'); session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']); ini_set('session.use_strict_mode', '1'); ini_set('session.gc_maxlifetime', '28800'); session_start(); $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); } private function csrfGueltig(): bool { return is_string($_POST['_csrf'] ?? null) && hash_equals((string) ($_SESSION['csrf'] ?? ''), $_POST['_csrf']); } private function hinweis(string $text, string $art): void { $_SESSION['hinweis'] = ['text' => $text, 'art' => $art]; } private function weiter(string $ziel): void { http_response_code(303); header('Location: '.$ziel); } private function kopfzeilen(): void { header("Content-Security-Policy: default-src 'none'; 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 $daten */ private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void { header('Content-Type: text/html; charset=UTF-8'); if ($ohneRahmen) { echo (new Ansicht((string) ($_SESSION['csrf'] ?? '')))->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); return; } $hinweis = $_SESSION['hinweis'] ?? null; unset($_SESSION['hinweis']); $v = new Ansicht((string) ($_SESSION['csrf'] ?? '')); $inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [ 'titel' => $daten['titel'] ?? 'Zentrale', 'inhalt' => $inhalt, 'hinweis' => $hinweis, 'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null, ]); } }