Files
kantarciandClaude Opus 5.5 bb736e8258
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
Make the registry search live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:13:38 +02:00

392 lines
16 KiB
PHP
Raw Permalink 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) {
$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<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');
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,
]);
}
}