diff --git a/config/services.yaml b/config/services.yaml index b8db3c4..4fb5c34 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -63,6 +63,9 @@ services: arguments: $secret: '%kernel.secret%' $siteOrigin: '%env(SITE_ORIGIN)%' + App\EventSubscriber\ErrorLogSubscriber: + arguments: + $debug: '%kernel.debug%' App\Controller\SeoController: arguments: $siteOrigin: '%env(SITE_ORIGIN)%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a03c3d8..0a89a04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,20 @@ Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026) + +Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“. + +**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite. + +**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite. + +**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen). + +**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update//alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt. + +**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200). + ## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026) Umut nach dem Update von kantarci-reisen.de auf 1.27.0: „Update durchgelaufen, direkt danach kam 500, dann bin ich zurück und hab migrieren können.“ Und: „Können wir den Updater nicht besser machen, dass das alles automatisch abläuft, einfach wie bei WordPress?“ diff --git a/docs/STATUS.md b/docs/STATUS.md index c63516a..a86bcdb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026, noch nicht veröffentlicht) + +**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“ + +**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank. + +**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung. + +**Bekannt:** Der neue Ablauf greift erst bei Updates, die von der nächsten Fassung ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig). + +**Nächster Schritt:** Release, dann kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren. + ## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026) **Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“ diff --git a/public/assets/admin.css b/public/assets/admin.css index 77a1450..9b8f41c 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -1049,3 +1049,34 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] > .language-texts h3 { margin: 0 0 6px; font-size: 15px } .language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 } .language-text-group legend { font-weight: 600; padding: 0 4px } +/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */ +.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px } +.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite } +@keyframes update-dreh { to { transform: rotate(360deg) } } +@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } } +.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start } +.update-neu h3 { margin: 0; font-size: 17px } +.update-neu .muted { margin: 0 } +.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 } +.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px } +.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start } +.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 } +.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% } +.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px } +.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 } +.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff } +.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite } +.update-schritt.is-offen { color: #8f9e96 } +.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff } +@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } } +@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } } +.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd } +.update-meldung.is-ok { color: #9fdcb5 } +.update-meldung.is-fehler { color: #ffb4a6 } +.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 } +.update-letztes { margin: 0 0 12px } +.update-manual { margin-top: 6px } +.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 } +.update-dialog::backdrop { background: #1f2d2766 } +.update-dialog h2 { margin: 0 0 10px; font-size: 18px } +.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px } diff --git a/public/assets/system-update.js b/public/assets/system-update.js new file mode 100644 index 0000000..7fcdc6d --- /dev/null +++ b/public/assets/system-update.js @@ -0,0 +1,190 @@ +/* + * Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft. + * + * Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren + * Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden, + * Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist + * der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code: + * Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück + * und die Seite sagt es. + * + * Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“. + */ +const karte = document.querySelector('[data-update]'); +if (karte) { + const live = karte.querySelector('[data-update-live]'); + const dialog = document.getElementById('update-dialog'); + const csrf = karte.dataset.csrf; + const SCHRITTE = [ + ['sichern', 'Daten sichern'], + ['laden', 'Fassung laden und prüfen'], + ['umschalten', 'Umschalten'], + ['migrieren', 'Datenbank anpassen'], + ['pruefen', 'Funktion prüfen'], + ]; + let ziel = null, timer = null, laeuft = false; + + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : ''; + const abfragen = async url => { + const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } }); + if (!antwort.ok) throw new Error(String(antwort.status)); + return antwort.json(); + }; + const senden = async (url, daten) => { + const antwort = await fetch(url, { + method: 'POST', credentials: 'same-origin', cache: 'no-store', + headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' }, + body: new URLSearchParams(daten), + }); + const text = await antwort.text(); + try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); } + }; + + // Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** - + // als Textknoten, nie als HTML: was dort steht, kommt von außen. + function notizen(text) { + const box = el('div', 'update-notizen'); + text.split('\n').forEach((zeile, index) => { + if (index) box.append(document.createElement('br')); + zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => { + box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil)); + }); + }); + return box; + } + + // ------------------------------------------------------------------ Ansicht: Prüfen + function warten(text) { + live.hidden = false; + live.replaceChildren(el('p', 'update-wait', text)); + live.firstChild.prepend(el('span', 'update-spinner')); + } + + function zeigeErgebnis(daten) { + live.hidden = false; + live.replaceChildren(); + const letzter = daten.status; + if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) { + const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`); + box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`)); + if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || ''); + live.append(box); + } + if (daten.fehler) { + const zeile = el('p', 'notice error', daten.fehler + ' '); + const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button'; + nochmal.addEventListener('click', pruefen); + zeile.append(nochmal); live.append(zeile); + return; + } + if (!daten.neu) { + live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`)); + return; + } + ziel = daten.neu.version; + const box = el('div', 'update-neu'); + box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar')); + box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`)); + box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · '))); + if (daten.neu.notizen) box.append(notizen(daten.neu.notizen)); + const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button'; + knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); }); + box.append(knopf); + live.append(box); + } + + async function pruefen() { + warten('Frage nach neuen Fassungen …'); + try { + const daten = await abfragen(karte.dataset.pruefen); + if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; } + zeigeErgebnis(daten); + } catch { + zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' }); + } + } + + // ------------------------------------------------------------------ Ansicht: Lauf + function zeigeLauf(status, server = true) { + live.hidden = false; + const box = el('div', 'update-lauf'); + const kopf = el('p', 'update-lauf-kopf'); + const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase); + if (!ende) kopf.append(el('span', 'update-spinner')); + kopf.append(ende + ? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`) + : `Update auf ${status.auf || ziel || ''} läuft`); + box.append(kopf); + // Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte. + const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase); + const aktuell = gescheitert + ? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei)) + : SCHRITTE.findIndex(([phase]) => phase === status.phase); + const liste = el('ol', 'update-schritte'); + SCHRITTE.forEach(([phase, text], index) => { + let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen'; + if (gescheitert && index === aktuell) zustand = 'fehler'; + const punkt = el('li', `update-schritt is-${zustand}`); + punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text)); + punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true'); + punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand])); + liste.append(punkt); + }); + box.append(liste); + const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`, + server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.'); + box.append(meldung); + if (ende) { + const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button'; + weiter.addEventListener('click', () => location.reload()); + box.append(weiter); + } + live.replaceChildren(box); + } + + async function tick() { + try { + const daten = await abfragen(karte.dataset.status); + if (daten.status) zeigeLauf(daten.status); + if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert; + if (daten.status && !daten.status.laeuft && !laeuft) stoppen(); + } catch { + // Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder. + const letzter = live.querySelector('.update-meldung'); + if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.'; + } + } + function verfolgen() { if (!timer) timer = setInterval(tick, 1500); } + function stoppen() { clearInterval(timer); timer = null; } + + async function starten() { + dialog.close(); + laeuft = true; + zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true }); + verfolgen(); + try { + const start = await senden(karte.dataset.starten, { version: ziel }); + if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status }); + const ende = await senden(karte.dataset.abschliessen, { token: start.token }); + laeuft = false; + await tick(); + stoppen(); + if (ende.ok) setTimeout(() => location.reload(), 2500); + } catch (fehler) { + laeuft = false; + await tick(); + stoppen(); + if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message }); + } + } + + dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close()); + dialog.querySelector('[data-update-los]').addEventListener('click', starten); + pruefen(); +} diff --git a/public/index.php b/public/index.php index 72dc5b7..1fee484 100644 --- a/public/index.php +++ b/public/index.php @@ -1,6 +1,7 @@ handle($request); -$response->send(); -$kernel->terminate($request, $response); +// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende +// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine +// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log. +require_once dirname(__DIR__).'/src/Ops/Notfall.php'; +Notfall::einschalten(dirname(__DIR__)); + +try { + require dirname(__DIR__).'/config/bootstrap.php'; + $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); + $request = Request::createFromGlobals(); + $response = $kernel->handle($request); + $response->send(); + $kernel->terminate($request, $response); +} catch (Throwable $fehler) { + Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? ''); +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 102848a..7acbf5b 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -3,6 +3,8 @@ namespace App\Controller; use App\Command\SiteOriginCommand; +use App\Content\PageRepository; +use App\Content\SiteSettings; use App\Domain\InvalidPage; use App\Ops\ArchiveUpdate; use App\Ops\Backup; @@ -11,8 +13,10 @@ use App\Ops\Installation; use App\Ops\OriginCheck; use App\Ops\Updater; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -24,7 +28,7 @@ use Symfony\Component\Routing\Attribute\Route; */ final class SystemController extends AbstractController { - public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck) + public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings) { } @@ -189,6 +193,113 @@ final class SystemController extends AbstractController return $this->redirectToRoute('admin_system'); } + // ----------------------------------------------------------------------------------------- + // Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt + // beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite + // zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“. + // ----------------------------------------------------------------------------------------- + + /** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */ + #[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])] + public function updateCheck(): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $installiert = $this->updater->version(); + $neueste = $this->archive->latest(); + $antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()]; + if (null === $neueste) { + $antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.'; + } elseif (version_compare($neueste['version'], $installiert, '>')) { + $antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)]; + } + + return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']); + } + + /** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */ + #[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])] + public function updateStatus(): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + + return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']); + } + + /** + * Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch + * als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite + * danach selbst auf. + */ + #[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])] + public function updateStart(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) { + return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403); + } + @set_time_limit(0); + $ergebnis = $this->archive->run($request->request->getString('version') ?: null); + if ($ergebnis['migrate'] ?? false) { + $this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]); + } + $letzter = end($ergebnis['steps']) ?: ['detail' => '']; + + return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]); + } + + /** + * Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der + * Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist + * wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte. + */ + #[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])] + public function updateFinish(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $offen = $this->archive->pending(); + if (null === $offen) { + return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]); + } + if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) { + return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403); + } + @set_time_limit(0); + $ergebnis = $this->archive->finish($this->installation, $this->pruefen(...)); + $this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]); + } + + /** + * Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite + * als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut. + */ + private function pruefen(): ?string + { + $home = $this->settings->homePath(); + if (null === $this->pages->findPublished($home)) { + return sprintf('Die Startseite /%s ist nicht lesbar.', $home); + } + $antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false); + if ($antwort->getStatusCode() >= 400) { + return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode()); + } + + return null; + } + + /** Wie sendAndStop, nur als JSON für die Systemseite. */ + private function sendJsonAndStop(array $daten): never + { + while (ob_get_level() > 0) { + ob_end_clean(); + } + http_response_code(200); + header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store'); + header('X-Robots-Tag: noindex'); + echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + /** * Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112). * diff --git a/src/EventSubscriber/ErrorLogSubscriber.php b/src/EventSubscriber/ErrorLogSubscriber.php new file mode 100644 index 0000000..523bb45 --- /dev/null +++ b/src/EventSubscriber/ErrorLogSubscriber.php @@ -0,0 +1,50 @@ + ['onException', -64]]; + } + + public function onException(ExceptionEvent $event): void + { + $fehler = $event->getThrowable(); + if ($event->hasResponse() || $fehler instanceof AuthenticationException || $fehler instanceof AccessDeniedException || $fehler instanceof LogoutException + || ($fehler instanceof HttpExceptionInterface && $fehler->getStatusCode() < 500)) { + return; + } + Notfall::protokollieren($fehler, $event->getRequest()->getMethod().' '.$event->getRequest()->getRequestUri()); + // Besucher sehen statt einer englischen „Oops“-Seite die Wartungsseite mit 503: für + // Suchmaschinen „gleich wieder da“. Im Entwicklungsmodus bleibt die ausführliche Seite. + if (!$this->debug) { + $event->setResponse(new Response(Notfall::html(), Response::HTTP_SERVICE_UNAVAILABLE, Notfall::KOPFZEILEN)); + } + } +} diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index dc26bd3..7af7051 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -51,6 +51,21 @@ final class ArchiveUpdate /** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */ private const PROTOKOLL = 'var/updates.log'; + /** + * Der Fortschritt für die Systemseite (ADR 113): Phase, Meldung, von welcher auf welche Fassung. + * Die Seite fragt ihn alle zwei Sekunden ab, während eine andere Anfrage das Update ausführt - + * das Vorbild ist reiseSoft. + */ + public const STATUS = 'var/update/status.json'; + + /** Die Phasen, die die Systemseite als Schritte zeigt, in ihrer Reihenfolge. */ + public const PHASEN = ['sichern', 'laden', 'umschalten', 'migrieren', 'pruefen']; + + /** Was mergePublic() überschrieben oder neu angelegt hat - für das Zurückschalten. */ + private array $publicAlt = []; + private array $publicNeu = []; + private ?string $publicSicherung = null; + public function __construct( private string $root, private Updater $updater, @@ -85,6 +100,10 @@ final class ArchiveUpdate $version = $treffer[1]; return [ + // Was die Releaseseite über die Fassung sagt - die Systemseite zeigt es vor dem Knopf. + 'name' => (string) ($daten['name'] ?? ''), + 'notes' => (string) ($daten['body'] ?? ''), + 'published' => (string) ($daten['published_at'] ?? ''), 'version' => $version, 'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version), 'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)), @@ -118,6 +137,12 @@ final class ArchiveUpdate $melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool { $schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail]; $this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail)); + // Ein gescheiterter Schritt vor dem Tausch: die bisherige Fassung läuft unverändert. + // Die Sperre meldet ein anderes, laufendes Update - dessen Fortschritt bleibt stehen. + // Ist die Releaseseite nicht erreichbar, hat nichts begonnen; dann bleibt alles, wie es ist. + if (!$ok && !in_array($schritt, ['Sperre', 'Fassung ermitteln'], true)) { + $this->phase('fehler', $detail.' Die bisherige Fassung läuft unverändert weiter.', ['laeuft' => false]); + } return $ok; }; @@ -136,6 +161,7 @@ final class ArchiveUpdate return ['ok' => true, 'steps' => $schritte]; } + $this->phase('sichern', 'Datenbank und Medien werden gesichert …', ['laeuft' => true, 'von' => $this->updater->version(), 'auf' => $ziel, 'seit' => gmdate('c')], true); $lock = $this->updater->lock(); if (null === $lock) { @@ -165,6 +191,7 @@ final class ArchiveUpdate try { $sicherung = $this->backup->create('vor dem Update'); $melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung'))); + $this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel)); } catch (\Throwable $fehler) { $melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.'); @@ -211,6 +238,7 @@ final class ArchiveUpdate } $melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.'); + $this->phase('umschalten', 'Wartungsseite an, die neuen Dateien werden eingesetzt …'); $this->updater->maintenance(true); $melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.'); @@ -229,11 +257,14 @@ final class ArchiveUpdate ++$ersetzt; } // public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem - // Betreiber und wären sonst weg. + // Betreiber und wären sonst weg. Was dabei überschrieben wird, kommt vorher beiseite, + // damit ein Zurückschalten auch public/ zurückbringt. + $this->publicSicherung = $arbeit.'/alt-public'; $this->mergePublic($quelle.'/public', $this->root.'/public'); // Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber // installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt. $this->mergePublic($quelle.'/plugins', $this->root.'/plugins'); + @file_put_contents($arbeit.'/public.json', json_encode(['alt' => $this->publicAlt, 'neu' => $this->publicNeu], JSON_UNESCAPED_SLASHES)); } catch (\Throwable $fehler) { foreach (self::ERSETZEN as $eintrag) { if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) { @@ -265,7 +296,11 @@ final class ArchiveUpdate 'seit' => gmdate('c'), 'alt' => basename($arbeit), 'token' => $token, + // Für das Zurückschalten: von welcher Fassung, und welche Sicherung dazu gehört. + 'von' => (string) ($this->status()['von'] ?? ''), + 'sicherung' => (string) ($sicherung['name'] ?? ''), ], JSON_UNESCAPED_SLASHES)); + $this->phase('migrieren', 'Die neuen Dateien sind da. Die Datenbank wird angepasst …'); $melden($schritte, 'Dateien sind ersetzt', true, sprintf( 'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.', $ziel, @@ -302,25 +337,49 @@ final class ArchiveUpdate * * @return array{ok: bool, steps: list} */ - public function finish(Installation $installation): array + /** + * @param (callable(): ?string)|null $pruefen prüft die neue Fassung nach den Migrationen und + * gibt null zurück oder den Grund, warum sie nicht + * läuft - dann wird zurückgeschaltet + */ + public function finish(Installation $installation, ?callable $pruefen = null): array { $schritte = []; + $offen = $this->pending(); $this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s'))); + $this->phase('migrieren', 'Die Datenbank wird angepasst …', ['laeuft' => true]); try { $namen = $installation->migrate(); $schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen ? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.' : sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))]; } catch (\Throwable $fehler) { - $schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.']; + $schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage()]; $this->protokoll('FEHLER Migrationen: '.$fehler->getMessage()); - return ['ok' => false, 'steps' => $schritte]; + return $this->zurueckschalten($offen, 'Die Datenbank ließ sich nicht anpassen: '.$fehler->getMessage(), $schritte); + } + $this->protokoll('ok Migrationen: '.$schritte[0]['detail']); + + if (null !== $pruefen) { + $this->phase('pruefen', 'Die neue Fassung wird geprüft …'); + try { + $problem = $pruefen(); + } catch (\Throwable $fehler) { + $problem = $fehler->getMessage(); + } + if (null !== $problem) { + $schritte[] = ['step' => 'Funktion prüfen', 'ok' => false, 'detail' => $problem]; + $this->protokoll('FEHLER Funktion prüfen: '.$problem); + + return $this->zurueckschalten($offen, 'Die neue Fassung lief nicht: '.$problem, $schritte); + } + $schritte[] = ['step' => 'Funktion prüfen', 'ok' => true, 'detail' => 'Startseite und Datenbank antworten.']; + $this->protokoll('ok Funktion prüfen: Startseite und Datenbank antworten.'); } @unlink($this->root.'/'.self::MARKE); $this->updater->maintenance(false); - $this->protokoll('ok Migrationen: '.$schritte[0]['detail']); foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) { @unlink($this->root.'/public/'.$eintrag); } @@ -329,10 +388,109 @@ final class ArchiveUpdate 'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).', $this->updater->version(), )]; + $this->phase('fertig', sprintf('Fassung %s läuft.', $this->updater->version()), ['laeuft' => false]); return ['ok' => true, 'steps' => $schritte]; } + /** + * Zurück auf die Fassung von vorher: Code aus var/update//alt, die überschriebenen + * Dateien in public/ und plugins/, dann die Datenbank aus der Sicherung vor dem Update. + * Erst danach geht die Wartungsseite aus - eine halb zurückgeschaltete Website darf niemand + * sehen. + */ + private function zurueckschalten(?array $offen, string $grund, array $schritte): array + { + $bei = $this->status()['phase'] ?? null; + $this->phase('zurueckschalten', 'Etwas ist schiefgegangen – die bisherige Fassung wird wiederhergestellt …', ['bei' => in_array($bei, self::PHASEN, true) ? $bei : null]); + $arbeit = $this->root.'/var/update/'.basename((string) ($offen['alt'] ?? '')); + $fehler = []; + if (null === $offen || !is_dir($arbeit.'/alt')) { + $fehler[] = 'Der beiseitegelegte Stand fehlt.'; + } else { + @mkdir($arbeit.'/verworfen', 0o775, true); + foreach (self::ERSETZEN as $eintrag) { + if (!file_exists($arbeit.'/alt/'.$eintrag)) { + continue; + } + if (file_exists($this->root.'/'.$eintrag) && !@rename($this->root.'/'.$eintrag, $arbeit.'/verworfen/'.$eintrag)) { + $fehler[] = $eintrag.' ließ sich nicht beiseitelegen.'; + continue; + } + if (!@rename($arbeit.'/alt/'.$eintrag, $this->root.'/'.$eintrag)) { + $fehler[] = $eintrag.' ließ sich nicht zurücklegen.'; + } + } + $liste = json_decode((string) @file_get_contents($arbeit.'/public.json'), true); + foreach ((array) ($liste['neu'] ?? []) as $relativ) { + @unlink($this->root.'/'.$relativ); + } + foreach ((array) ($liste['alt'] ?? []) as $relativ) { + if (!@copy($arbeit.'/alt-public/'.$relativ, $this->root.'/'.$relativ)) { + $fehler[] = $relativ.' ließ sich nicht zurückkopieren.'; + } + } + } + if ('' !== (string) ($offen['sicherung'] ?? '')) { + $wieder = $this->backup->restore((string) $offen['sicherung']); + if (!$wieder['ok']) { + $fehler[] = 'Die Datenbank ließ sich nicht aus der Sicherung zurückholen.'; + } + } + $von = (string) ($offen['von'] ?? ''); + if ([] === $fehler) { + @unlink($this->root.'/'.self::MARKE); + $this->updater->maintenance(false); + $meldung = sprintf('%s Zurückgeschaltet auf %s – die Website läuft wie vorher.', $grund, '' === $von ? 'die vorherige Fassung' : $von); + $schritte[] = ['step' => 'Zurückgeschaltet', 'ok' => true, 'detail' => $meldung]; + $this->protokoll('ok Zurückgeschaltet: '.$meldung); + $this->phase('zurueckgerollt', $meldung, ['laeuft' => false]); + } else { + // Bleibt die Wartungsseite an, sieht niemand eine halbe Website; der Rest ist Handarbeit. + $meldung = $grund.' Das Zurückschalten gelang nicht vollständig: '.implode(' ', $fehler).' Die Wartungsseite bleibt an.'; + $schritte[] = ['step' => 'Zurückschalten', 'ok' => false, 'detail' => $meldung]; + $this->protokoll('FEHLER Zurückschalten: '.$meldung); + $this->phase('fehler', $meldung, ['laeuft' => false]); + } + + return ['ok' => false, 'steps' => $schritte, 'zurueckgeschaltet' => [] === $fehler]; + } + + /** Der letzte Fortschritt, oder null, wenn noch nie ein Update über diesen Weg lief. */ + public function status(): ?array + { + $daten = json_decode((string) @file_get_contents($this->root.'/'.self::STATUS), true); + if (!is_array($daten)) { + return null; + } + // Ein Lauf, der seit einer halben Stunde nichts mehr meldet, ist abgebrochen - etwa weil + // das Hosting den Aufruf beendet hat. Dann soll die Seite nicht ewig „läuft“ zeigen. + if (($daten['laeuft'] ?? false) && strtotime((string) ($daten['aktualisiert'] ?? '')) < time() - 1800) { + $daten['laeuft'] = false; + $daten['phase'] = 'fehler'; + $daten['meldung'] = 'Das Update hat seit einer halben Stunde nichts mehr gemeldet und gilt als abgebrochen. Den Stand zeigt das Protokoll unten.'; + } + + return $daten; + } + + /** Schreibt den Fortschritt - in einem Zug, damit die Seite nie eine halbe Datei liest. */ + private function phase(string $phase, string $meldung, array $mehr = [], bool $neu = false): void + { + $daten = $neu ? [] : ($this->status() ?? []); + // Bei einem Fehler zählt, in welchem Schritt er passierte - die Seite markiert genau den. + if (in_array($phase, ['fehler', 'zurueckgerollt'], true) && !array_key_exists('bei', $mehr)) { + $mehr['bei'] = in_array($daten['phase'] ?? null, self::PHASEN, true) ? $daten['phase'] : ($daten['bei'] ?? null); + } + $daten = [...$daten, ...$mehr, 'phase' => $phase, 'meldung' => $meldung, 'aktualisiert' => gmdate('c')]; + $datei = $this->root.'/'.self::STATUS; + @mkdir(dirname($datei), 0o775, true); + $zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp'; + if (false !== @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) { + @rename($zwischen, $datei); + } + } + /** * Eine Zeile ins Protokoll, und zwar sofort. * @@ -366,6 +524,7 @@ final class ArchiveUpdate continue; } + $this->merken($ziel); if ('.htaccess' === $eintrag && is_file($ziel)) { $this->mergeHtaccess($quelle, $ziel); @@ -375,6 +534,25 @@ final class ArchiveUpdate } } + /** Merkt sich eine Datei, bevor sie überschrieben oder neu angelegt wird. */ + private function merken(string $ziel): void + { + if (null === $this->publicSicherung) { + return; + } + $relativ = ltrim(substr($ziel, strlen($this->root)), '/'); + if (!is_file($ziel)) { + $this->publicNeu[] = $relativ; + + return; + } + $kopie = $this->publicSicherung.'/'.$relativ; + @mkdir(dirname($kopie), 0o775, true); + if (@copy($ziel, $kopie)) { + $this->publicAlt[] = $relativ; + } + } + /** * Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat, * eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten diff --git a/src/Ops/Notfall.php b/src/Ops/Notfall.php new file mode 100644 index 0000000..0bf0200 --- /dev/null +++ b/src/Ops/Notfall.php @@ -0,0 +1,123 @@ + 'text/html; charset=UTF-8', + 'Retry-After' => '300', + 'Cache-Control' => 'no-store', + 'X-Robots-Tag' => 'noindex', + 'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'", + ]; + + private static ?string $wurzel = null; + private static bool $gezeigt = false; + + /** Einmal ganz am Anfang von public/index.php. */ + public static function einschalten(string $wurzel): void + { + self::$wurzel = $wurzel; + register_shutdown_function(static function (): void { + $fehler = error_get_last(); + if (null === $fehler || !in_array($fehler['type'], self::ABBRUCH, true)) { + return; + } + self::melden(sprintf('PHP-Abbruch: %s in %s:%d', $fehler['message'], $fehler['file'], $fehler['line'])); + self::seite(); + // Danach soll nichts mehr ausgeben - auch nicht die Fehlerseite des Frameworks, das + // sich ebenfalls fürs Herunterfahren angemeldet hat. exit in einer Shutdown-Funktion + // beendet die übrigen. + exit; + }); + } + + /** Eine Ausnahme, die aus dem Framework herausgefallen ist. */ + public static function ausnahme(\Throwable $fehler, string $adresse = ''): void + { + self::melden(self::beschreiben($fehler, $adresse)); + self::seite(); + } + + /** Ein Fehler, den das Framework abgefangen und als 500 beantwortet hat - nur protokollieren. */ + public static function protokollieren(\Throwable $fehler, string $adresse = ''): void + { + self::melden(self::beschreiben($fehler, $adresse)); + } + + public static function beschreiben(\Throwable $fehler, string $adresse = ''): string + { + $zeile = sprintf('%s: %s in %s:%d', $fehler::class, $fehler->getMessage(), $fehler->getFile(), $fehler->getLine()); + // Die eigentliche Ursache steckt oft eine Ebene tiefer, etwa das SQLite-Problem unter + // einer Doctrine-Ausnahme. + for ($tiefer = $fehler->getPrevious(), $n = 0; null !== $tiefer && $n < 3; $tiefer = $tiefer->getPrevious(), ++$n) { + $zeile .= sprintf(' | Ursache: %s: %s', $tiefer::class, $tiefer->getMessage()); + } + + return ('' === $adresse ? '' : $adresse.' – ').$zeile; + } + + private static function melden(string $text): void + { + $wurzel = self::$wurzel ?? dirname(__DIR__, 2); + $datei = $wurzel.'/'.self::PROTOKOLL; + if (!is_dir(dirname($datei))) { + @mkdir(dirname($datei), 0o775, true); + } + if (is_file($datei) && filesize($datei) > self::GRENZE) { + @rename($datei, $datei.'.1'); + } + // Zeilenumbrüche aus Meldungen würden das Protokoll zerreißen; eine Zeile je Ereignis. + $text = str_replace(["\r", "\n"], ' ', $text); + @file_put_contents($datei, sprintf("[%s UTC] %s\n", gmdate('Y-m-d H:i:s'), mb_substr($text, 0, 4000)), FILE_APPEND | LOCK_EX); + } + + /** Die Seite für Besucher. Ohne Details, ohne Abhängigkeiten, ohne fremde Quellen. */ + private static function seite(): void + { + if (self::$gezeigt || PHP_SAPI === 'cli') { + return; + } + self::$gezeigt = true; + while (ob_get_level() > 0) { + @ob_end_clean(); + } + if (!headers_sent()) { + http_response_code(503); + foreach (self::KOPFZEILEN as $name => $wert) { + header($name.': '.$wert); + } + } + echo self::html(); + } + + /** Die Wartungsseite als Text, auch für den Fehler-Listener im Framework. */ + public static function html(): string + { + return '' + .'Gleich wieder da' + .'

Die Website ist gleich wieder da.

Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.

' + .'

This website will be back shortly.

'; + } +} diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index fe0bf22..7a4355f 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -1,6 +1,7 @@ {% extends 'admin/shell.html.twig' %} {% set nav = 'system' %} {% block title %}System{% endblock %} +{% block scripts %}{% endblock %} {% block heading %}System und Update{% endblock %} {% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %} {% block content %} @@ -137,10 +138,17 @@ {% endif %} -
+{# Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Beim Öffnen fragt die Seite + selbst nach einer neuen Fassung, zeigt die Hinweise dazu und einen Knopf. Danach laufen die + Schritte sichtbar durch - bis „abgeschlossen“ oder „zurückgeschaltet“. Ohne Skript bleiben die + Formulare darunter. #} +
-

{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}

- {{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }} +

Update

+ Installiert {{ state.version }}
{% if archivePending %} @@ -150,35 +158,30 @@ - {% else %} -

Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.

- {# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #} -
- Was unberührt bleibt -

var/, .env, .env.local, die geladenen Schriften und das erzeugte Stylesheet-Bündel.

-

Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.

-
- {% if archiveVersion %} -

Verfügbar: {{ archiveVersion }} (installiert: {{ state.version }}).

- {% elseif asked %} -

Die Releaseseite nennt nichts Neueres als {{ state.version }}.

- {% else %} -

Nachsehen, ob es etwas Neues gibt – dauert einen Moment.

{% endif %} -
- -
-
- - -

Leer lassen für die neueste veröffentlichte Fassung.

+ +
+ Weitere Möglichkeiten +

Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungsseite an, Dateien tauschen, Datenbank anpassen, prüfen. Unberührt bleiben var/, .env, die geladenen Schriften und das erzeugte Stylesheet-Bündel.

+ + +
+
+ + +

Leer lassen für die neueste veröffentlichte Fassung. Dieser Weg funktioniert auch ohne JavaScript.

+
-
- - - {% endif %} + + +
+ +

Auf aktualisieren?

+

Vorher werden Datenbank und Medien automatisch gesichert. Die Website zeigt etwa eine Minute lang eine Wartungsseite. Schlägt etwas fehl, wird automatisch auf {{ state.version }} zurückgeschaltet.

+
+
{% if state.worktree %}
diff --git a/tests/ArchiveUpdateTest.php b/tests/ArchiveUpdateTest.php index a2062bf..596d77a 100644 --- a/tests/ArchiveUpdateTest.php +++ b/tests/ArchiveUpdateTest.php @@ -2,14 +2,24 @@ namespace App\Tests; +use App\Content\DocumentValidator; +use App\Content\MediaLibrary; +use App\Content\MenuRepository; +use App\Content\PageRepository; +use App\Content\PartialRepository; +use App\Content\SiteSettings; use App\Infrastructure\ConnectionFactory; use App\Ops\ArchiveUpdate; use App\Ops\Backup; use App\Ops\CommandRunner; use App\Ops\Download; +use App\Ops\Installation; use App\Ops\Updater; +use App\Security\CmsUser; use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; /** * Das Update über das Releasearchiv - der Weg für Installationen ohne Git. @@ -272,6 +282,77 @@ final class ArchiveUpdateTest extends TestCase } } + public function testTheProgressFileFollowsTheUpdateAndAFinishedOneSaysSo(): void + { + // Die Systemseite fragt diesen Fortschritt ab, während eine andere Anfrage das Update + // ausführt (ADR 113) - Phase, Meldung, von welcher auf welche Fassung. + $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); + $updater = $this->updater(); + self::assertNull($updater->status()); + self::assertSame('Fortschritt live.', $updater->latest()['notes'] ?? null, 'Die Hinweise der Releaseseite gehören zur neuen Fassung.'); + + $ergebnis = $updater->run(); + self::assertTrue($ergebnis['ok'], $this->explain($ergebnis)); + $status = $updater->status(); + self::assertSame(['migrieren', true, '1.0.0', '1.1.0'], [$status['phase'], $status['laeuft'], $status['von'], $status['auf']]); + + $fertig = $this->updater()->finish($this->installation()); + self::assertTrue($fertig['ok'], $this->explain($fertig)); + self::assertSame(['fertig', false], [$this->updater()->status()['phase'], $this->updater()->status()['laeuft']]); + self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE); + } + + public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void + { + // Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte + // zurück - Code, public/ und Datenbank -, und erst dann geht die Wartungsseite aus. + $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); + $this->db->insert('website', ['id' => 'default', 'name' => 'Vorher', 'default_locale' => 'de', 'theme' => '{}']); + $ergebnis = $this->updater()->run(); + self::assertTrue($ergebnis['ok'], $this->explain($ergebnis)); + self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION'))); + // Etwas, das die neue Fassung in der Datenbank anrichtet, bevor die Prüfung scheitert. + $this->db->update('website', ['name' => 'Nachher'], ['id' => 'default']); + + $ende = $this->updater()->finish($this->installation(), static fn (): string => 'Die Startseite antwortet mit 500.'); + + self::assertFalse($ende['ok']); + self::assertTrue($ende['zurueckgeschaltet'] ?? false, $this->explain($ende)); + self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION'))); + self::assertFileExists($this->root.'/src/Alt.php'); + self::assertFileDoesNotExist($this->root.'/src/Neu.php'); + self::assertSame("root.'/public/index.php'), 'Auch public/ kommt zurück.'); + self::assertSame('body{color:alt}', file_get_contents($this->root.'/public/assets/site.css')); + self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2')); + self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2'); + $this->db->close(); + $this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db'); + self::assertSame('Vorher', $this->db->fetchOne("SELECT name FROM website WHERE id = 'default'"), 'Die Datenbank kommt aus der Sicherung vor dem Update.'); + self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Zurückgeschaltet heißt: die Website läuft wieder.'); + self::assertNull($this->updater()->pending()); + $status = $this->updater()->status(); + self::assertSame(['zurueckgerollt', 'pruefen', false], [$status['phase'], $status['bei'], $status['laeuft']], 'Gescheitert ist die Prüfung - genau dieser Schritt wird markiert.'); + self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']); + } + + private function installation(): Installation + { + $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')); + $validator = new DocumentValidator(); + + return new Installation( + $this->db, + new PageRepository($this->db, $validator, $settings), + new PartialRepository($this->db, $validator), + new MenuRepository($this->db, $settings), + $settings, + new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])), + $this->root, + $this->root.'/var/media', + 'test-secret', + ); + } + private function updater(): ArchiveUpdate { $runner = new CommandRunner(); @@ -303,7 +384,8 @@ final class ArchiveUpdateTest extends TestCase 'bin/console' => " "services:\n", 'templates/base.html.twig' => 'neu', - 'migrations.php' => " " ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n", + 'migrations/.keep' => '', 'cli-config.php' => " '{"name":"neu"}', 'composer.lock' => '{"packages":["neu"]}', diff --git a/tests/NotfallTest.php b/tests/NotfallTest.php new file mode 100644 index 0000000..c03a61a --- /dev/null +++ b/tests/NotfallTest.php @@ -0,0 +1,91 @@ +root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-notfall-'.bin2hex(random_bytes(6)); + mkdir($this->root, 0o775, true); + Notfall::einschalten($this->root); + } + + protected function tearDown(): void + { + foreach (glob($this->root.'/var/log/*') ?: [] as $datei) { + unlink($datei); + } + @rmdir($this->root.'/var/log'); + @rmdir($this->root.'/var'); + @rmdir($this->root); + } + + private function ereignis(\Throwable $fehler): ExceptionEvent + { + $kernel = $this->createStub(HttpKernelInterface::class); + + return new ExceptionEvent($kernel, Request::create('/leistungen'), HttpKernelInterface::MAIN_REQUEST, $fehler); + } + + public function testAServerErrorBecomesAMaintenancePageAndALogLine(): void + { + $ursache = new \PDOException('file is not a database'); + $ereignis = $this->ereignis(new \RuntimeException('Abfrage fehlgeschlagen', 0, $ursache)); + + (new ErrorLogSubscriber(debug: false))->onException($ereignis); + + $antwort = $ereignis->getResponse(); + self::assertNotNull($antwort); + self::assertSame(503, $antwort->getStatusCode(), 'Für Suchmaschinen: gleich wieder da, nicht kaputt.'); + self::assertSame('300', $antwort->headers->get('Retry-After')); + self::assertStringContainsString('Die Website ist gleich wieder da.', (string) $antwort->getContent()); + self::assertStringNotContainsString('database', (string) $antwort->getContent(), 'Einzelheiten gehören ins Protokoll, nicht auf die Seite.'); + $protokoll = (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL); + self::assertStringContainsString('GET /leistungen – RuntimeException: Abfrage fehlgeschlagen', $protokoll); + self::assertStringContainsString('Ursache: PDOException: file is not a database', $protokoll, 'Die eigentliche Ursache steckt eine Ebene tiefer.'); + } + + public function testANotFoundIsNoEmergencyAndDevelopmentKeepsItsDetailedPage(): void + { + $nichtGefunden = $this->ereignis(new NotFoundHttpException('Gibt es nicht')); + (new ErrorLogSubscriber(debug: false))->onException($nichtGefunden); + self::assertNull($nichtGefunden->getResponse()); + self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL); + + // „Zugriff verweigert“ führt zur Anmeldung - die Sicherheitsschicht entscheidet, nicht der Notfall. + $verweigert = $this->ereignis(new \Symfony\Component\Security\Core\Exception\AccessDeniedException()); + (new ErrorLogSubscriber(debug: false))->onException($verweigert); + self::assertNull($verweigert->getResponse()); + self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL); + + $entwicklung = $this->ereignis(new \LogicException('Fehler beim Entwickeln')); + (new ErrorLogSubscriber(debug: true))->onException($entwicklung); + self::assertNull($entwicklung->getResponse(), 'Im Entwicklungsmodus bleibt die ausführliche Fehlerseite.'); + self::assertStringContainsString('LogicException: Fehler beim Entwickeln', (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL)); + } + + public function testTheFrontControllerLoadsTheEmergencyLayerBeforeAnythingElse(): void + { + $einstieg = (string) file_get_contents(dirname(__DIR__).'/public/index.php'); + self::assertLessThan(strpos($einstieg, 'config/bootstrap.php'), strpos($einstieg, 'Notfall::einschalten'), 'Scheitert schon der Start, muss die Notfallschicht stehen.'); + self::assertStringContainsString('catch (Throwable $fehler)', $einstieg); + } +} diff --git a/tests/ReleaseOnDisk.php b/tests/ReleaseOnDisk.php index 42141a2..b098a7c 100644 --- a/tests/ReleaseOnDisk.php +++ b/tests/ReleaseOnDisk.php @@ -28,7 +28,7 @@ final class ReleaseOnDisk extends Download /** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */ public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void { - $this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR)); + $this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version, 'name' => 'KantSites '.$version, 'body' => 'Fortschritt live.'], JSON_THROW_ON_ERROR)); $this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv); $this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf( "%s kantsites-%s.zip\n", diff --git a/tests/SystemScreenTest.php b/tests/SystemScreenTest.php index 73f3248..d8fc784 100644 --- a/tests/SystemScreenTest.php +++ b/tests/SystemScreenTest.php @@ -306,6 +306,38 @@ final class SystemScreenTest extends WebTestCase } } + public function testTheOneClickUpdateEndpointsAreForAdministratorsOnly(): void + { + // Der Fortschritt für die Systemseite (ADR 113): Redakteure sehen ihn nicht, ein Start + // braucht das Token der Sitzung, ein Abschluss den Einmalschlüssel des laufenden Updates. + $redaktion = $this->login('editor'); + $redaktion->request('GET', '/admin/system/update/status'); + self::assertSame(403, $redaktion->getResponse()->getStatusCode()); + + $admin = $this->login(); + $admin->request('GET', '/admin/system/update/status'); + self::assertSame(200, $admin->getResponse()->getStatusCode()); + self::assertArrayHasKey('installiert', json_decode((string) $admin->getResponse()->getContent(), true)); + + $admin->request('POST', '/admin/system/update/starten', [], [], ['HTTP_X_CSRF_TOKEN' => 'erfunden']); + self::assertSame(403, $admin->getResponse()->getStatusCode()); + self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent()); + + $admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'egal']); + self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent(), 'Ohne offenes Update gibt es nichts abzuschließen.'); + + $marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE; + @mkdir(dirname($marke), 0o775, true); + file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('b', 32)])); + try { + $admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'falsch']); + self::assertSame(403, $admin->getResponse()->getStatusCode()); + self::assertFileExists($marke); + } finally { + @unlink($marke); + } + } + public function testNobodyButAnAdministratorCanStartAMigration(): void { // Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.