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,15 @@
|
||||
# Für den Fall, dass die Subdomain auf diesen Ordner zeigt statt auf public/:
|
||||
# app/, var/ und config.php bleiben verschlossen, alles andere geht nach public/.
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^(app|var)(/|$) - [F,L]
|
||||
RewriteRule ^(config\.php|config\.example\.php|README\.md)$ - [F,L]
|
||||
RewriteCond %{REQUEST_URI} !^/public/
|
||||
RewriteRule ^(.*)$ public/$1 [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_rewrite.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
@@ -0,0 +1,78 @@
|
||||
# KantSites Zentrale (sites.kant-it.de)
|
||||
|
||||
Die Übersicht aller KantSites-Installationen: welche Domain, welche Fassung, wann zuletzt
|
||||
gemeldet, und ein Knopf für Updates – einzeln oder für alle. Entscheidung und Protokoll:
|
||||
ADR 123 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
Reines PHP (ab 8.1) mit SQLite, ohne Composer und ohne Framework. Nötig sind die Erweiterungen
|
||||
`pdo_sqlite` und `curl` (oder `allow_url_fopen`) – beides ist auf üblichem Webhosting da.
|
||||
|
||||
## Hochladen
|
||||
|
||||
1. Den Ordner `zentrale/` komplett per FTP hochladen, zum Beispiel nach `/sites.kant-it.de/`.
|
||||
2. Die Subdomain `sites.kant-it.de` auf den Unterordner **`public/`** zeigen lassen.
|
||||
Geht das beim Hoster nicht, auf den Ordner selbst – die `.htaccess` dort leitet nach `public/`
|
||||
und sperrt `app/`, `var/` und `config.php`.
|
||||
3. HTTPS (Let's Encrypt) für die Subdomain einschalten. Die Installationen melden nur über HTTPS.
|
||||
4. `config.example.php` als `config.php` kopieren und einen zufälligen `einrichtungscode`
|
||||
(mindestens 16 Zeichen) eintragen.
|
||||
5. `var/` muss für PHP beschreibbar sein (dort liegen `zentrale.db` und der Fassungs-Cache).
|
||||
6. `https://sites.kant-it.de` aufrufen, mit dem Einrichtungscode das erste Konto anlegen und
|
||||
anmelden. Danach wird der Code nicht mehr gebraucht.
|
||||
|
||||
Prüfen: `https://sites.kant-it.de/api/melden` im Browser antwortet mit
|
||||
`{"ok":false,"fehler":"Nur POST."}` – dann ist die Schnittstelle erreichbar.
|
||||
|
||||
## Wie die Installationen sich melden
|
||||
|
||||
Ab KantSites **1.36.0** meldet sich jede Installation alle 15 Minuten über die Aufgabe
|
||||
„Bei der Zentrale melden“ (Verwaltung → Aufgaben). Sie läuft wie alle Aufgaben beim Besuch,
|
||||
in der Verwaltung oder über den URL-Cronjob. Ältere Installationen erscheinen erst nach dem Update
|
||||
auf 1.36.0 – das letzte Mal von Hand über „System“.
|
||||
|
||||
Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `127.0.0.1`,
|
||||
`*.test` oder `*.localhost` zeigt. Abschalten pro Installation: `ZENTRALE_URL=aus` in `.env`.
|
||||
|
||||
Übertragen werden: Adresse, Fassung, PHP-Version, Datenbankart (sqlite/mysql), Git oder
|
||||
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
|
||||
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
|
||||
|
||||
## Update aus der Ferne
|
||||
|
||||
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
|
||||
2. Die Installation holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an,
|
||||
lädt das Release mit Prüfsumme, tauscht die Dateien und ruft sich selbst einmal auf. Diese
|
||||
Anfrage führt mit dem neuen Code die Migrationen aus, prüft die Startseite und gibt die
|
||||
Website frei – oder schaltet auf die alte Fassung zurück.
|
||||
3. Das Ergebnis erscheint mit der nächsten Meldung in der Zentrale.
|
||||
|
||||
Einschränkungen:
|
||||
|
||||
- Git-Arbeitskopien werden nicht aus der Ferne aktualisiert.
|
||||
- Erreicht sich eine Website nicht selbst (manche Hoster sperren das), schließt der nächste
|
||||
Besucher oder Cron-Aufruf das Update ab. So lange zeigt sie die Wartungsseite.
|
||||
- Ein abgeholter Auftrag ohne Ergebnis nach 2 Stunden erscheint als „keine Rückmeldung“.
|
||||
|
||||
## Fremde Installationen erkennen
|
||||
|
||||
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
|
||||
**Unbekannt** in der Liste. Meldet sich eine bekannte Kennung unter einer anderen Domain, zeigt die
|
||||
Liste „vorher …“. Wer KantSites verändert und die Meldung ausbaut, taucht nicht auf – einen
|
||||
vollständigen Schutz gibt es ohne Lizenzschlüssel nicht.
|
||||
|
||||
## Lokal ausprobieren
|
||||
|
||||
```bash
|
||||
php -S 127.0.0.1:8115 -t zentrale/public zentrale/public/index.php
|
||||
```
|
||||
|
||||
Protokollprüfung zwischen CMS und Zentrale (ohne Netz):
|
||||
|
||||
```bash
|
||||
php scripts/zentrale-check.php
|
||||
```
|
||||
|
||||
## Sicherung
|
||||
|
||||
Alles Wichtige steht in `var/zentrale.db`. Geht sie verloren, melden sich die Installationen bei
|
||||
ihrer nächsten Meldung neu an; Kundenzuordnungen und Auftragsverlauf sind dann weg.
|
||||
@@ -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 } ?>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
// Als config.php neben diese Datei kopieren und den Einrichtungscode setzen.
|
||||
// config.php gehört nicht ins Repository.
|
||||
|
||||
return [
|
||||
// Nur für das erste Konto: Wer ihn kennt, kann das erste Konto anlegen. Danach wird er nicht
|
||||
// mehr gebraucht. Mindestens 16 Zeichen, zufällig - etwa aus einem Passwortmanager.
|
||||
'einrichtungscode' => '',
|
||||
|
||||
// Wo die neueste KantSites-Fassung steht. Dieselbe Releaseseite fragen die Installationen.
|
||||
'releases_api' => 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteCond %{HTTP_HOST} !^(localhost|127\.0\.0\.1)(:\d+)?$
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
|
||||
</IfModule>
|
||||
@@ -0,0 +1,159 @@
|
||||
:root {
|
||||
--grund: #f4f6f9;
|
||||
--flaeche: #ffffff;
|
||||
--text: #17202b;
|
||||
--leise: #5a6575;
|
||||
--linie: #dde3ea;
|
||||
--marke: #173f6b;
|
||||
--marke-hell: #e6eef7;
|
||||
--ok: #17663a;
|
||||
--ok-grund: #e3f4e9;
|
||||
--warn: #8a4b00;
|
||||
--warn-grund: #fdf0dc;
|
||||
--fehler: #a4161a;
|
||||
--fehler-grund: #fde7e7;
|
||||
--still: #5b4a8a;
|
||||
--still-grund: #eeeaf8;
|
||||
--fokus: #1f6feb;
|
||||
--radius: 10px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--grund);
|
||||
color: var(--text);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
a { color: var(--marke); text-underline-offset: 2px; }
|
||||
a:hover { text-decoration-thickness: 2px; }
|
||||
|
||||
:focus-visible { outline: 3px solid var(--fokus); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
h1 { font-size: 1.6rem; line-height: 1.25; margin: 0; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 .6rem; }
|
||||
p { margin: 0 0 .8rem; }
|
||||
code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font-size: .92em; }
|
||||
|
||||
.skip { position: absolute; left: -9999px; top: 8px; background: var(--flaeche); padding: 8px 12px; border-radius: 6px; z-index: 10; }
|
||||
.skip:focus { left: 16px; }
|
||||
|
||||
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
.kopf { background: var(--marke); color: #fff; }
|
||||
.kopf-innen { max-width: 1180px; margin: 0 auto; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.marke { color: #fff; text-decoration: none; font-weight: 700; font-size: 1.05rem; display: inline-flex; align-items: center; gap: 10px; }
|
||||
.marke span:last-child { font-weight: 400; opacity: .85; }
|
||||
.marke-zeichen { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; background: #fff; color: var(--marke); font-weight: 800; }
|
||||
.abmelden { display: flex; align-items: center; gap: 10px; margin: 0; }
|
||||
.wer { font-size: .9rem; opacity: .9; }
|
||||
.kopf .knopf-leise { color: #fff; border-color: rgba(255, 255, 255, .5); background: transparent; }
|
||||
.kopf .knopf-leise:hover { background: rgba(255, 255, 255, .12); }
|
||||
.kopf :focus-visible { outline-color: #fff; }
|
||||
|
||||
.seite { max-width: 1180px; margin: 0 auto; padding: 24px 16px 48px; }
|
||||
.seite:focus { outline: none; }
|
||||
|
||||
.titelzeile { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.neueste { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--leise); flex-wrap: wrap; }
|
||||
.neueste strong { color: var(--text); }
|
||||
.zurueck { margin-bottom: 8px; }
|
||||
|
||||
.karte { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 18px; }
|
||||
.schmal { max-width: 440px; margin: 32px auto; }
|
||||
.schmal h1 { margin-bottom: 14px; }
|
||||
.raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 18px; align-items: start; }
|
||||
|
||||
.zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; }
|
||||
.zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; }
|
||||
.z-ok strong { color: var(--ok); }
|
||||
.z-warn strong { color: var(--warn); }
|
||||
.z-still strong { color: var(--still); }
|
||||
.z-fremd strong { color: var(--fehler); }
|
||||
|
||||
.alle { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-left: 4px solid var(--marke); }
|
||||
.alle > div { flex: 1 1 420px; }
|
||||
.alle p { color: var(--leise); }
|
||||
.bestaetigen { display: flex; align-items: center; gap: 8px; font-weight: 600; margin: 4px 0 0; }
|
||||
.bestaetigen input { width: 18px; height: 18px; margin: 0; accent-color: var(--marke); }
|
||||
|
||||
.tabelle-rahmen { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); overflow: hidden; }
|
||||
.karte .tabelle-rahmen { border: 0; border-radius: 0; }
|
||||
.tabelle { width: 100%; border-collapse: collapse; }
|
||||
.tabelle th, .tabelle td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--linie); vertical-align: middle; }
|
||||
.tabelle thead th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--leise); background: #f9fafc; font-weight: 600; }
|
||||
.tabelle tbody tr:last-child > * { border-bottom: 0; }
|
||||
.tabelle tbody th { font-weight: 600; }
|
||||
.tabelle tbody tr:hover { background: #fafbfd; }
|
||||
.aktion { text-align: right; white-space: nowrap; }
|
||||
.aktion form { margin: 0; }
|
||||
|
||||
.klein { color: var(--leise); font-size: .85rem; }
|
||||
.block { display: block; margin-top: 3px; }
|
||||
th .klein { display: block; font-weight: 400; }
|
||||
.still { color: var(--still); font-weight: 600; }
|
||||
|
||||
.fassung { font-variant-numeric: tabular-nums; font-weight: 600; padding: 2px 8px; border-radius: 999px; font-size: .9rem; }
|
||||
.f-ok { background: var(--ok-grund); color: var(--ok); }
|
||||
.f-alt { background: var(--warn-grund); color: var(--warn); }
|
||||
|
||||
.pille { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 600; }
|
||||
.p-ok { background: var(--ok-grund); color: var(--ok); }
|
||||
.p-fehler { background: var(--fehler-grund); color: var(--fehler); }
|
||||
.p-warn, .p-wartet { background: var(--warn-grund); color: var(--warn); }
|
||||
.p-laeuft { background: var(--marke-hell); color: var(--marke); }
|
||||
.marke-fremd { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 700; background: var(--fehler-grund); color: var(--fehler); }
|
||||
|
||||
.fakten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
||||
.fakten dt { color: var(--leise); }
|
||||
.fakten dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
label { display: block; font-weight: 600; margin: 12px 0 4px; }
|
||||
.feldhilfe { color: var(--leise); font-size: .88rem; margin: -2px 0 6px; }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text);
|
||||
}
|
||||
input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-color: var(--fokus); }
|
||||
|
||||
.knopf {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 8px 16px;
|
||||
font: inherit; font-weight: 600; color: #fff; background: var(--marke); border: 1px solid var(--marke); border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
.knopf:hover { background: #0f2f52; }
|
||||
.knopf-klein { min-height: 34px; padding: 5px 12px; font-size: .9rem; }
|
||||
.knopf-leise { background: var(--flaeche); color: var(--marke); border-color: #b9c3cf; min-height: 34px; padding: 5px 12px; font-size: .9rem; }
|
||||
.knopf-leise:hover { background: var(--marke-hell); }
|
||||
.knopf-gefahr { background: var(--fehler); border-color: var(--fehler); }
|
||||
.knopf-gefahr:hover { background: #7f1013; }
|
||||
.abstand { margin-top: 14px; }
|
||||
|
||||
.hinweis { padding: 10px 14px; border-radius: 8px; margin-bottom: 18px; font-weight: 500; }
|
||||
.hinweis-ok { background: var(--ok-grund); color: var(--ok); }
|
||||
.hinweis-fehler { background: var(--fehler-grund); color: var(--fehler); }
|
||||
|
||||
.gefahr summary { cursor: pointer; font-weight: 600; color: var(--fehler); }
|
||||
.gefahr[open] summary { margin-bottom: 10px; }
|
||||
.gefahr .knopf { margin-top: 12px; }
|
||||
|
||||
.leer p { color: var(--leise); margin: 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
h1 { font-size: 1.35rem; }
|
||||
.karte { padding: 16px; }
|
||||
.tabelle thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.tabelle, .tabelle tbody, .tabelle tr, .tabelle th, .tabelle td { display: block; width: 100%; }
|
||||
.tabelle tr { padding: 10px 14px; border-bottom: 1px solid var(--linie); }
|
||||
.tabelle tbody tr:last-child { border-bottom: 0; }
|
||||
.tabelle th, .tabelle td { border: 0; padding: 3px 0; }
|
||||
.tabelle td[data-label]::before { content: attr(data-label) ": "; color: var(--leise); font-size: .85rem; }
|
||||
.tabelle tbody th { font-size: 1.05rem; padding-bottom: 6px; }
|
||||
.aktion { text-align: left; padding-top: 8px !important; }
|
||||
.fakten { grid-template-columns: 1fr; gap: 0; }
|
||||
.fakten dd { margin-bottom: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$wurzel = dirname(__DIR__);
|
||||
|
||||
// Der eingebaute Server von PHP liefert Dateien aus public/ selbst aus.
|
||||
if ('cli-server' === PHP_SAPI && is_file(__DIR__.parse_url((string) $_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=UTF-8');
|
||||
echo "Die Zentrale braucht PHP 8.1 oder neuer.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
spl_autoload_register(static function (string $klasse) use ($wurzel): void {
|
||||
if (str_starts_with($klasse, 'Zentrale\\')) {
|
||||
$datei = $wurzel.'/app/'.substr($klasse, 9).'.php';
|
||||
if (is_file($datei)) {
|
||||
require $datei;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$config = is_file($wurzel.'/config.php') ? require $wurzel.'/config.php' : [];
|
||||
$config = is_array($config) ? $config : [];
|
||||
$config += ['releases_api' => 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites', 'einrichtungscode' => ''];
|
||||
|
||||
(new Zentrale\Anwendung($config, $wurzel))->bearbeiten();
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
Reference in new issue
Block a user