Add the central site registry and remote updates
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>
This commit is contained in:
1 parent
32cb77b72e
commit
723680b7de
39 files changed
+2355
-27
No files matched your search
@@ -0,0 +1,336 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user