Every installation now reports address and version to sites.kant-it.de every 15 minutes and picks up update orders there (ADR 123). Reports and answers are HMAC-signed with a per-installation key sent on first contact; an order can only install a published release with checksum. The next request after the file swap runs the migrations, so a remote update finishes without anyone in the admin. The platform itself lives in zentrale/ (plain PHP + SQLite, not part of the release archive): overview with outdated, silent and unassigned installations, single and bulk updates, per-site detail and history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
14 KiB
PHP
337 lines
14 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
|
|
{
|
|
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);
|
|
$this->zeigen('uebersicht', [
|
|
'titel' => 'Installationen',
|
|
'neueste' => $neueste,
|
|
'installationen' => $alle,
|
|
'zahlen' => [
|
|
'alle' => count($alle),
|
|
'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])),
|
|
'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])),
|
|
'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])),
|
|
'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])),
|
|
],
|
|
// 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,
|
|
]);
|
|
}
|
|
}
|