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 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* Vorlagen als PHP-Dateien. Alles, was ausgegeben wird, geht durch e().
|
||||
*/
|
||||
final class Ansicht
|
||||
{
|
||||
public function __construct(private string $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
public function vorlage(string $datei, array $daten): string
|
||||
{
|
||||
$v = $this;
|
||||
extract($daten, EXTR_SKIP);
|
||||
ob_start();
|
||||
require $datei;
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
public function e(mixed $wert): string
|
||||
{
|
||||
return htmlspecialchars((string) $wert, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
public function csrf(): string
|
||||
{
|
||||
return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">';
|
||||
}
|
||||
|
||||
/** Ein Zeitpunkt in deutscher Ortszeit. */
|
||||
public function zeit(?string $iso): string
|
||||
{
|
||||
if (null === $iso || '' === $iso || false === strtotime($iso)) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
return (new \DateTimeImmutable($iso))->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('d.m.Y, H:i');
|
||||
}
|
||||
|
||||
/** „vor 5 Minuten“ - für die letzte Meldung. */
|
||||
public function vor(int $sekunden): string
|
||||
{
|
||||
return match (true) {
|
||||
$sekunden < 90 => 'gerade eben',
|
||||
$sekunden < 5400 => sprintf('vor %d Minuten', (int) round($sekunden / 60)),
|
||||
$sekunden < 129600 => sprintf('vor %d Stunden', (int) round($sekunden / 3600)),
|
||||
default => sprintf('vor %d Tagen', (int) round($sekunden / 86400)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* POST /api/melden - die Meldung einer KantSites-Installation.
|
||||
*
|
||||
* Gegenstück zu App\Ops\Zentrale im CMS. Beim ersten Kontakt bringt die Installation ihren
|
||||
* Schlüssel mit; danach muss jede Meldung damit unterschrieben sein (HMAC-SHA256 über den
|
||||
* Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die
|
||||
* Installation nur Aufträge von hier annimmt.
|
||||
*/
|
||||
final class Api
|
||||
{
|
||||
private const GROESSTE_MELDUNG = 16384;
|
||||
|
||||
public function __construct(private \PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: int, body: string, signatur: ?string}
|
||||
*/
|
||||
public function melden(string $body, string $signatur, string $ip): array
|
||||
{
|
||||
if (strlen($body) > self::GROESSTE_MELDUNG) {
|
||||
return $this->fehler(413, 'Die Meldung ist zu groß.');
|
||||
}
|
||||
$daten = json_decode($body, true);
|
||||
if (!is_array($daten)) {
|
||||
return $this->fehler(400, 'Die Meldung ist kein JSON.');
|
||||
}
|
||||
$pruef = $this->pruefen($daten);
|
||||
if (is_string($pruef)) {
|
||||
return $this->fehler(400, $pruef);
|
||||
}
|
||||
$signatur = strtolower(trim($signatur));
|
||||
|
||||
$gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
|
||||
$gespeichert->execute([$daten['id']]);
|
||||
$installation = $gespeichert->fetch() ?: null;
|
||||
$mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null;
|
||||
|
||||
if (null === $installation) {
|
||||
if (null === $mitgebracht) {
|
||||
// Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt
|
||||
// daraufhin ihren Schlüssel noch einmal.
|
||||
return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.');
|
||||
}
|
||||
$schluessel = $mitgebracht;
|
||||
} else {
|
||||
$schluessel = (string) $installation['schluessel'];
|
||||
if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) {
|
||||
return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.');
|
||||
}
|
||||
}
|
||||
if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) {
|
||||
return $this->fehler(403, 'Die Unterschrift stimmt nicht.');
|
||||
}
|
||||
|
||||
$jetzt = Datenbank::jetzt();
|
||||
$domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST));
|
||||
$update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
|
||||
$werte = [
|
||||
'adresse' => $daten['adresse'],
|
||||
'domain' => $domain,
|
||||
'fassung' => $daten['fassung'],
|
||||
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
|
||||
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
||||
'art' => 'git' === ($daten['art'] ?? '') ? 'git' : 'archiv',
|
||||
'wartung' => true === ($daten['wartung'] ?? false) ? 1 : 0,
|
||||
'update_phase' => isset($update['phase']) ? mb_substr((string) $update['phase'], 0, 30) : null,
|
||||
'update_meldung' => isset($update['meldung']) ? mb_substr((string) $update['meldung'], 0, 300) : null,
|
||||
'update_zeit' => isset($update['zeit']) ? mb_substr((string) $update['zeit'], 0, 40) : null,
|
||||
'ip' => mb_substr($ip, 0, 45),
|
||||
'letzte_meldung' => $jetzt,
|
||||
];
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
if (null === $installation) {
|
||||
$sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')';
|
||||
$this->db->prepare($sql)->execute([$daten['id'], $schluessel, $jetzt, ...array_values($werte)]);
|
||||
} else {
|
||||
// Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation
|
||||
// kopiert oder umgezogen. Beides will man sehen.
|
||||
if ($installation['domain'] !== $domain) {
|
||||
$werte['domain_vorher'] = $installation['domain'];
|
||||
}
|
||||
$sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?';
|
||||
$this->db->prepare($sql)->execute([...array_values($werte), $daten['id']]);
|
||||
}
|
||||
|
||||
$ergebnis = $daten['auftrag'] ?? null;
|
||||
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) {
|
||||
$this->db->prepare('UPDATE auftrag SET erledigt = ?, ok = ?, meldung = ?, abgeholt = COALESCE(abgeholt, ?) WHERE id = ? AND installation = ? AND erledigt IS NULL')
|
||||
->execute([$jetzt, $ergebnis['ok'] ? 1 : 0, mb_substr((string) ($ergebnis['meldung'] ?? ''), 0, 500), $jetzt, $ergebnis['id'], $daten['id']]);
|
||||
}
|
||||
|
||||
$offen = $this->db->prepare('SELECT id, aktion, fassung FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL ORDER BY id DESC LIMIT 1');
|
||||
$offen->execute([$daten['id']]);
|
||||
$auftrag = $offen->fetch() ?: null;
|
||||
if (null !== $auftrag) {
|
||||
$this->db->prepare('UPDATE auftrag SET abgeholt = ? WHERE id = ?')->execute([$jetzt, $auftrag['id']]);
|
||||
$auftrag = ['id' => (int) $auftrag['id'], 'aktion' => (string) $auftrag['aktion'], 'fassung' => (string) $auftrag['fassung']];
|
||||
}
|
||||
$this->db->commit();
|
||||
} catch (\Throwable $fehler) {
|
||||
$this->db->rollBack();
|
||||
throw $fehler;
|
||||
}
|
||||
|
||||
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
|
||||
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function pruefen(array $daten): ?string
|
||||
{
|
||||
if (!is_string($daten['id'] ?? null) || 1 !== preg_match('/^[a-f0-9]{32}$/D', $daten['id'])) {
|
||||
return 'Die Kennung fehlt oder hat die falsche Form.';
|
||||
}
|
||||
$adresse = $daten['adresse'] ?? null;
|
||||
if (!is_string($adresse) || strlen($adresse) > 200 || 1 !== preg_match('#^https?://[a-z0-9.-]+(:\d{1,5})?(/[^\s]*)?$#iD', $adresse)) {
|
||||
return 'Die Adresse fehlt oder ist keine Webadresse.';
|
||||
}
|
||||
if (!is_string($daten['fassung'] ?? null) || 1 !== preg_match('/^(\d{1,4}\.\d{1,5}\.\d{1,6}|unbekannt)$/D', $daten['fassung'])) {
|
||||
return 'Die Fassung hat nicht die Form 1.2.3.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array{status: int, body: string, signatur: null} */
|
||||
private function fehler(int $status, string $text): array
|
||||
{
|
||||
return ['status' => $status, 'body' => (string) json_encode(['ok' => false, 'fehler' => $text], JSON_UNESCAPED_UNICODE), 'signatur' => null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* Die Installationen und ihre Aufträge - was die Übersicht zeigt und was ihre Knöpfe tun.
|
||||
*/
|
||||
final class Bestand
|
||||
{
|
||||
/** Ohne Meldung seit so vielen Stunden gilt eine Installation als verstummt. */
|
||||
public const STILL_STUNDEN = 48;
|
||||
|
||||
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
|
||||
public const AUFTRAG_STUNDEN = 2;
|
||||
|
||||
public function __construct(private \PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function alle(?string $neueste): array
|
||||
{
|
||||
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
|
||||
$auftraege = [];
|
||||
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
|
||||
$auftraege[$auftrag['installation']] = $auftrag;
|
||||
}
|
||||
|
||||
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function eine(string $id, ?string $neueste): ?array
|
||||
{
|
||||
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
|
||||
$abfrage->execute([$id]);
|
||||
$zeile = $abfrage->fetch() ?: null;
|
||||
if (null === $zeile) {
|
||||
return null;
|
||||
}
|
||||
$verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30');
|
||||
$verlauf->execute([$id]);
|
||||
$auftraege = $verlauf->fetchAll();
|
||||
$eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste);
|
||||
$eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege);
|
||||
|
||||
return $eine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht.
|
||||
*/
|
||||
public function beauftragen(string $id, string $fassung): ?string
|
||||
{
|
||||
$eine = $this->eine($id, $fassung);
|
||||
if (null === $eine) {
|
||||
return 'Diese Installation gibt es nicht.';
|
||||
}
|
||||
if (!$eine['kann_update']) {
|
||||
return $eine['kein_update_grund'];
|
||||
}
|
||||
$offen = $eine['auftrag'];
|
||||
if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) {
|
||||
$this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]);
|
||||
|
||||
return null;
|
||||
}
|
||||
$this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)')
|
||||
->execute([$id, 'update', $fassung, Datenbank::jetzt()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Einen noch nicht abgeholten Auftrag zurückziehen. */
|
||||
public function zurueckziehen(string $id): bool
|
||||
{
|
||||
$abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL');
|
||||
$abfrage->execute([$id]);
|
||||
|
||||
return $abfrage->rowCount() > 0;
|
||||
}
|
||||
|
||||
public function notiz(string $id, string $notiz): void
|
||||
{
|
||||
$this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]);
|
||||
}
|
||||
|
||||
public function entfernen(string $id): void
|
||||
{
|
||||
$this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $zeile
|
||||
* @param array<string, mixed>|null $auftrag
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array
|
||||
{
|
||||
$alter = time() - (int) strtotime((string) $zeile['letzte_meldung']);
|
||||
$veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<');
|
||||
$zustand = null === $auftrag ? null : $this->auftragZustand($auftrag);
|
||||
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
|
||||
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
|
||||
|
||||
$grund = match (true) {
|
||||
null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
|
||||
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
|
||||
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
|
||||
$laeuft => 'Ein Update läuft gerade.',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return [
|
||||
...$zeile,
|
||||
'alter' => $alter,
|
||||
'still' => $alter > self::STILL_STUNDEN * 3600,
|
||||
'veraltet' => $veraltet,
|
||||
'unbekannt' => '' === trim((string) $zeile['notiz']),
|
||||
'auftrag' => $auftrag,
|
||||
'auftrag_zustand' => $zustand,
|
||||
'kann_update' => '' === $grund,
|
||||
'kein_update_grund' => $grund,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $auftrag
|
||||
*
|
||||
* @return array{art: string, text: string}
|
||||
*/
|
||||
private function auftragZustand(array $auftrag): array
|
||||
{
|
||||
if (null !== $auftrag['erledigt']) {
|
||||
return (int) $auftrag['ok'] === 1
|
||||
? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])]
|
||||
: ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])];
|
||||
}
|
||||
if (null === $auftrag['abgeholt']) {
|
||||
return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])];
|
||||
}
|
||||
if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) {
|
||||
return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])];
|
||||
}
|
||||
|
||||
return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* SQLite in var/zentrale.db. Das Schema legt sich beim ersten Aufruf selbst an; spätere
|
||||
* Änderungen kommen als nummerierte Schritte in MIGRATIONEN dazu, nie als Änderung eines alten.
|
||||
*/
|
||||
final class Datenbank
|
||||
{
|
||||
private const MIGRATIONEN = [
|
||||
1 => [
|
||||
'CREATE TABLE konto (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, passwort TEXT NOT NULL, erstellt TEXT NOT NULL)',
|
||||
'CREATE TABLE anmeldeversuch (ip TEXT NOT NULL, zeit TEXT NOT NULL)',
|
||||
'CREATE INDEX anmeldeversuch_ip ON anmeldeversuch (ip, zeit)',
|
||||
'CREATE TABLE installation (
|
||||
id TEXT PRIMARY KEY,
|
||||
schluessel TEXT NOT NULL,
|
||||
adresse TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
domain_vorher TEXT,
|
||||
fassung TEXT NOT NULL,
|
||||
php TEXT NOT NULL DEFAULT \'\',
|
||||
datenbank TEXT NOT NULL DEFAULT \'\',
|
||||
art TEXT NOT NULL DEFAULT \'archiv\',
|
||||
wartung INTEGER NOT NULL DEFAULT 0,
|
||||
update_phase TEXT,
|
||||
update_meldung TEXT,
|
||||
update_zeit TEXT,
|
||||
notiz TEXT NOT NULL DEFAULT \'\',
|
||||
ip TEXT NOT NULL DEFAULT \'\',
|
||||
erste_meldung TEXT NOT NULL,
|
||||
letzte_meldung TEXT NOT NULL
|
||||
)',
|
||||
'CREATE TABLE auftrag (
|
||||
id INTEGER PRIMARY KEY,
|
||||
installation TEXT NOT NULL REFERENCES installation (id) ON DELETE CASCADE,
|
||||
aktion TEXT NOT NULL,
|
||||
fassung TEXT NOT NULL,
|
||||
erstellt TEXT NOT NULL,
|
||||
abgeholt TEXT,
|
||||
erledigt TEXT,
|
||||
ok INTEGER,
|
||||
meldung TEXT
|
||||
)',
|
||||
'CREATE INDEX auftrag_installation ON auftrag (installation, id)',
|
||||
],
|
||||
];
|
||||
|
||||
public static function oeffnen(string $datei): \PDO
|
||||
{
|
||||
$neu = !is_file($datei);
|
||||
if ($neu && !is_dir(dirname($datei)) && !@mkdir(dirname($datei), 0o775, true)) {
|
||||
throw new \RuntimeException(dirname($datei).' lässt sich nicht anlegen.');
|
||||
}
|
||||
$pdo = new \PDO('sqlite:'.$datei, null, null, [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||
if ($neu) {
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
}
|
||||
self::migrieren($pdo);
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
private static function migrieren(\PDO $pdo): void
|
||||
{
|
||||
$stand = (int) $pdo->query('PRAGMA user_version')->fetchColumn();
|
||||
foreach (self::MIGRATIONEN as $nummer => $anweisungen) {
|
||||
if ($nummer <= $stand) {
|
||||
continue;
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($anweisungen as $sql) {
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
$pdo->exec('PRAGMA user_version = '.$nummer);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $fehler) {
|
||||
$pdo->rollBack();
|
||||
throw $fehler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function jetzt(): string
|
||||
{
|
||||
return gmdate('Y-m-d\TH:i:s\Z');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* Die neueste veröffentlichte KantSites-Fassung - von derselben Releaseseite, die auch die
|
||||
* Installationen fragen. Eine halbe Stunde zwischengespeichert; ist die Releaseseite nicht
|
||||
* erreichbar, gilt der letzte bekannte Stand.
|
||||
*/
|
||||
final class Fassungen
|
||||
{
|
||||
private const GUELTIG = 1800;
|
||||
|
||||
public function __construct(private string $api, private string $cache)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array{version: string, name: string, published: string}|null */
|
||||
public function neueste(bool $frisch = false): ?array
|
||||
{
|
||||
$gespeichert = json_decode((string) @file_get_contents($this->cache), true);
|
||||
$gespeichert = is_array($gespeichert) && is_string($gespeichert['version'] ?? null) ? $gespeichert : null;
|
||||
if (!$frisch && null !== $gespeichert && (int) ($gespeichert['geholt'] ?? 0) > time() - self::GUELTIG) {
|
||||
return $gespeichert;
|
||||
}
|
||||
$neu = $this->holen();
|
||||
if (null === $neu) {
|
||||
return $gespeichert;
|
||||
}
|
||||
@file_put_contents($this->cache, json_encode([...$neu, 'geholt' => time()]));
|
||||
|
||||
return $neu;
|
||||
}
|
||||
|
||||
/** @return array{version: string, name: string, published: string}|null */
|
||||
private function holen(): ?array
|
||||
{
|
||||
if (!str_starts_with($this->api, 'https://')) {
|
||||
return null;
|
||||
}
|
||||
$kontext = stream_context_create([
|
||||
'http' => ['timeout' => 8, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Zentrale\r\nAccept: application/json\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init(rtrim($this->api, '/').'/releases/latest');
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_USERAGENT => 'KantSites-Zentrale',
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$antwort = 200 === $status ? $antwort : false;
|
||||
} else {
|
||||
$antwort = @file_get_contents(rtrim($this->api, '/').'/releases/latest', false, $kontext);
|
||||
}
|
||||
$daten = is_string($antwort) ? json_decode($antwort, true) : null;
|
||||
if (!is_array($daten) || 1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', (string) ($daten['tag_name'] ?? ''), $treffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['version' => $treffer[1], 'name' => (string) ($daten['name'] ?? ''), 'published' => (string) ($daten['published_at'] ?? '')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Zentrale;
|
||||
|
||||
/**
|
||||
* Anmeldung: Name und Passwort, gehasht mit password_hash() (bcrypt/argon2 je nach PHP).
|
||||
*
|
||||
* Nach fünf Fehlversuchen in 15 Minuten nimmt diese IP-Adresse eine Viertelstunde lang keine
|
||||
* Anmeldung mehr an.
|
||||
*/
|
||||
final class Konten
|
||||
{
|
||||
private const VERSUCHE = 5;
|
||||
private const SPERRE_SEKUNDEN = 900;
|
||||
|
||||
public function __construct(private \PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function gibtEs(): bool
|
||||
{
|
||||
return (int) $this->db->query('SELECT COUNT(*) FROM konto')->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
public function anlegen(string $name, string $passwort): void
|
||||
{
|
||||
$this->db->prepare('INSERT INTO konto (name, passwort, erstellt) VALUES (?, ?, ?)')
|
||||
->execute([$name, password_hash($passwort, PASSWORD_DEFAULT), Datenbank::jetzt()]);
|
||||
}
|
||||
|
||||
public function gesperrt(string $ip): bool
|
||||
{
|
||||
$abfrage = $this->db->prepare('SELECT COUNT(*) FROM anmeldeversuch WHERE ip = ? AND zeit > ?');
|
||||
$abfrage->execute([$ip, gmdate('Y-m-d\TH:i:s\Z', time() - self::SPERRE_SEKUNDEN)]);
|
||||
|
||||
return (int) $abfrage->fetchColumn() >= self::VERSUCHE;
|
||||
}
|
||||
|
||||
/** @return array{id: int, name: string}|null */
|
||||
public function pruefen(string $name, string $passwort, string $ip): ?array
|
||||
{
|
||||
$abfrage = $this->db->prepare('SELECT id, name, passwort FROM konto WHERE name = ?');
|
||||
$abfrage->execute([$name]);
|
||||
$konto = $abfrage->fetch() ?: null;
|
||||
// Auch ohne Konto einmal hashen: Die Antwortzeit verrät sonst, welche Namen es gibt.
|
||||
$hash = null !== $konto ? (string) $konto['passwort'] : password_hash('attrappe', PASSWORD_DEFAULT);
|
||||
if (!password_verify($passwort, $hash) || null === $konto) {
|
||||
$this->fehlversuch($ip);
|
||||
|
||||
return null;
|
||||
}
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$this->db->prepare('UPDATE konto SET passwort = ? WHERE id = ?')->execute([password_hash($passwort, PASSWORD_DEFAULT), $konto['id']]);
|
||||
}
|
||||
$this->db->prepare('DELETE FROM anmeldeversuch WHERE ip = ?')->execute([$ip]);
|
||||
|
||||
return ['id' => (int) $konto['id'], 'name' => (string) $konto['name']];
|
||||
}
|
||||
|
||||
public function fehlversuch(string $ip): void
|
||||
{
|
||||
$this->db->prepare('INSERT INTO anmeldeversuch (ip, zeit) VALUES (?, ?)')->execute([$ip, Datenbank::jetzt()]);
|
||||
$this->db->prepare('DELETE FROM anmeldeversuch WHERE zeit < ?')->execute([gmdate('Y-m-d\TH:i:s\Z', time() - 86400)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div class="schmal karte">
|
||||
<h1>Anmelden</h1>
|
||||
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert" id="fehler"><?= $v->e($fehler) ?></p><?php } ?>
|
||||
<form method="post" action="/anmelden">
|
||||
<?= $v->csrf() ?>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" autocomplete="username" required value="<?= $v->e($name) ?>" <?= null !== $fehler ? 'aria-describedby="fehler"' : '' ?>>
|
||||
<label for="passwort">Passwort</label>
|
||||
<input id="passwort" name="passwort" type="password" autocomplete="current-password" required>
|
||||
<button type="submit" class="knopf abstand">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div class="schmal karte">
|
||||
<h1>Zentrale einrichten</h1>
|
||||
<p>Es gibt noch kein Konto. Lege das erste an – dafür brauchst du den Einrichtungscode aus <code>config.php</code>.</p>
|
||||
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert"><?= $v->e($fehler) ?></p><?php } ?>
|
||||
<form method="post" action="/">
|
||||
<?= $v->csrf() ?>
|
||||
<label for="code">Einrichtungscode</label>
|
||||
<input id="code" name="code" type="password" autocomplete="off" required>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" autocomplete="username" required minlength="3" maxlength="60" value="<?= $v->e($name) ?>">
|
||||
<label for="passwort">Passwort</label>
|
||||
<p class="feldhilfe" id="pw-hilfe">Mindestens 12 Zeichen.</p>
|
||||
<input id="passwort" name="passwort" type="password" autocomplete="new-password" required minlength="12" aria-describedby="pw-hilfe">
|
||||
<label for="passwort2">Passwort wiederholen</label>
|
||||
<input id="passwort2" name="passwort2" type="password" autocomplete="new-password" required minlength="12">
|
||||
<button type="submit" class="knopf abstand">Konto anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div class="schmal karte">
|
||||
<h1>Nicht gefunden</h1>
|
||||
<p>Diese Seite gibt es nicht. <a href="/">Zur Übersicht</a></p>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<p class="zurueck"><a href="/">← Alle Installationen</a></p>
|
||||
<div class="titelzeile">
|
||||
<h1><?= $v->e($i['domain']) ?></h1>
|
||||
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
|
||||
</div>
|
||||
|
||||
<div class="raster">
|
||||
<section class="karte" aria-labelledby="t-stand">
|
||||
<h2 id="t-stand">Stand</h2>
|
||||
<dl class="fakten">
|
||||
<dt>Adresse</dt><dd><a href="<?= $v->e($i['adresse']) ?>" rel="noopener noreferrer"><?= $v->e($i['adresse']) ?></a></dd>
|
||||
<?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?>
|
||||
<dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – neueste ist '.$v->e($neueste['version']) : '' ?></dd>
|
||||
<dt>Aktualisiert über</dt><dd><?= 'git' === $i['art'] ? 'Git-Arbeitskopie' : 'Releasearchiv' ?></dd>
|
||||
<dt>PHP</dt><dd><?= $v->e($i['php'] ?: '–') ?></dd>
|
||||
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
|
||||
<dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></dd>
|
||||
<dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd>
|
||||
<dt>Erste Meldung</dt><dd><?= $v->e($v->zeit($i['erste_meldung'])) ?></dd>
|
||||
<dt>IP-Adresse</dt><dd><?= $v->e($i['ip'] ?: '–') ?></dd>
|
||||
<?php if (null !== $i['update_phase']) { ?>
|
||||
<dt>Letztes Update</dt><dd><?= $v->e($i['update_meldung']) ?> <span class="klein">(<?= $v->e($v->zeit($i['update_zeit'])) ?>)</span></dd>
|
||||
<?php } ?>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="karte" aria-labelledby="t-update">
|
||||
<h2 id="t-update">Update</h2>
|
||||
<?php if (null !== $i['auftrag_zustand']) { ?>
|
||||
<p><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span></p>
|
||||
<?php } ?>
|
||||
<?php if ('wartet' === ($i['auftrag_zustand']['art'] ?? null)) { ?>
|
||||
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab – meist innerhalb von 15 Minuten.</p>
|
||||
<?php } elseif ($i['kann_update'] && null !== $neueste) { ?>
|
||||
<form method="post" action="/auftrag">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<input type="hidden" name="zurueck" value="detail">
|
||||
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an, spielt <?= $v->e($neueste['version']) ?> ein und schaltet bei einem Fehler zurück.</p>
|
||||
<button type="submit" class="knopf">Auf <?= $v->e($neueste['version']) ?> aktualisieren</button>
|
||||
</form>
|
||||
<?php } else { ?>
|
||||
<p><?= $v->e($i['kein_update_grund']) ?></p>
|
||||
<?php } ?>
|
||||
<?php if (null !== $i['auftrag'] && null === $i['auftrag']['abgeholt'] && null === $i['auftrag']['erledigt']) { ?>
|
||||
<form method="post" action="/auftrag/zurueckziehen" class="abstand">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<button type="submit" class="knopf knopf-leise">Auftrag zurückziehen</button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</section>
|
||||
|
||||
<section class="karte" aria-labelledby="t-kunde">
|
||||
<h2 id="t-kunde">Kunde</h2>
|
||||
<form method="post" action="/notiz">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<label for="notiz">Kunde oder Notiz</label>
|
||||
<p class="feldhilfe" id="notiz-hilfe">Installationen ohne Eintrag stehen in der Übersicht als „Unbekannt“ – so fällt eine fremde Nutzung auf.</p>
|
||||
<input id="notiz" name="notiz" type="text" maxlength="200" value="<?= $v->e($i['notiz']) ?>" aria-describedby="notiz-hilfe">
|
||||
<button type="submit" class="knopf abstand">Speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="karte" aria-labelledby="t-verlauf">
|
||||
<h2 id="t-verlauf">Aufträge</h2>
|
||||
<?php if ([] === $i['verlauf']) { ?>
|
||||
<p>Noch kein Auftrag.</p>
|
||||
<?php } else { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<thead><tr><th scope="col">Beauftragt</th><th scope="col">Fassung</th><th scope="col">Abgeholt</th><th scope="col">Ergebnis</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($i['verlauf'] as $a) { ?>
|
||||
<tr>
|
||||
<td data-label="Beauftragt"><?= $v->e($v->zeit($a['erstellt'])) ?></td>
|
||||
<td data-label="Fassung"><?= $v->e($a['fassung']) ?></td>
|
||||
<td data-label="Abgeholt"><?= $v->e($v->zeit($a['abgeholt'])) ?></td>
|
||||
<td data-label="Ergebnis"><span class="pille p-<?= $v->e($a['zustand']['art']) ?>"><?= $v->e($a['zustand']['text']) ?></span><?php if ('' !== (string) $a['meldung']) { ?><span class="klein block"><?= $v->e($a['meldung']) ?></span><?php } ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</section>
|
||||
|
||||
<details class="karte gefahr">
|
||||
<summary>Eintrag entfernen</summary>
|
||||
<form method="post" action="/entfernen">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<p>Entfernt die Installation und ihre Aufträge aus dieser Liste. An der Website ändert sich nichts; meldet sie sich wieder, erscheint sie neu.</p>
|
||||
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, <?= $v->e($i['domain']) ?> aus der Liste entfernen</label>
|
||||
<button type="submit" class="knopf knopf-gefahr">Entfernen</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
|
||||
<link rel="stylesheet" href="/app.css?v=1">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#inhalt">Zum Inhalt</a>
|
||||
<header class="kopf">
|
||||
<div class="kopf-innen">
|
||||
<a class="marke" href="/"><span class="marke-zeichen" aria-hidden="true">K</span> KantSites <span>Zentrale</span></a>
|
||||
<?php if (null !== $konto) { ?>
|
||||
<form method="post" action="/abmelden" class="abmelden">
|
||||
<?= $v->csrf() ?>
|
||||
<span class="wer"><?= $v->e($konto['name']) ?></span>
|
||||
<button type="submit" class="knopf knopf-leise">Abmelden</button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</header>
|
||||
<main id="inhalt" class="seite" tabindex="-1">
|
||||
<?php if (null !== $hinweis) { ?>
|
||||
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
|
||||
<?php } ?>
|
||||
<?= $inhalt ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div class="titelzeile">
|
||||
<h1>Installationen</h1>
|
||||
<form method="post" action="/fassung" class="neueste">
|
||||
<?= $v->csrf() ?>
|
||||
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span>
|
||||
<button type="submit" class="knopf knopf-leise">Neu abfragen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ul class="zahlen" aria-label="Zusammenfassung">
|
||||
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li>
|
||||
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li>
|
||||
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li>
|
||||
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li>
|
||||
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li>
|
||||
</ul>
|
||||
|
||||
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
|
||||
<form method="post" action="/auftrag/alle" class="karte alle">
|
||||
<?= $v->csrf() ?>
|
||||
<div>
|
||||
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
|
||||
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_column($aktualisierbar, 'domain'))) ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p>
|
||||
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
|
||||
</div>
|
||||
<button type="submit" class="knopf">Alle aktualisieren</button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ([] === $installationen) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Noch keine Meldung</h2>
|
||||
<p>Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.</p>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<caption class="unsichtbar">Alle gemeldeten Installationen</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($installationen as $i) { ?>
|
||||
<tr>
|
||||
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
|
||||
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
|
||||
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
|
||||
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
|
||||
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
|
||||
<td data-label="Zustand">
|
||||
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
|
||||
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
|
||||
</td>
|
||||
<td class="aktion">
|
||||
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
|
||||
<form method="post" action="/auftrag">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php } ?>
|
||||
Reference in new issue
Block a user