Files
KantSites/zentrale/app/Anwendung.php
T
kantarciandClaude Opus 5.5 7cdb431fd4
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 search, filters and pages to the central registry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:04:46 +02:00

379 lines
15 KiB
PHP

<?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)));
$this->zeigen('uebersicht', [
'titel' => 'Installationen',
'neueste' => $neueste,
'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite),
'treffer' => count($treffer),
'suche' => $suche,
'filter' => $gewaehlt,
'seite' => $seite,
'seiten' => $seiten,
'von' => [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1,
'bis' => min(count($treffer), $seite * $proSeite),
'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))),
]);
}
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'; 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): void
{
header('Content-Type: text/html; charset=UTF-8');
$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,
]);
}
}