Compare commits
2
Commits
339a48c810
...
436c66ecd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436c66ecd3 | ||
|
|
a6a334fe68 |
No files matched your search
@@ -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)%'
|
||||
|
||||
@@ -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/<lauf>/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?“
|
||||
|
||||
@@ -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.“
|
||||
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
+17
-6
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
|
||||
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
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);
|
||||
// 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'] ?? '');
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\LogoutException;
|
||||
|
||||
/**
|
||||
* Jeder Serverfehler, den das Framework abfängt, steht auch in var/log/notfall.log (ADR 113).
|
||||
*
|
||||
* Ohne das gab es auf dem Hosting kein eigenes Protokoll: Symfony schreibt nach stderr, und das
|
||||
* landet - wenn überhaupt - im Fehlerlog des Hostings, das ein Betreiber erst suchen muss. 404 und
|
||||
* andere gewollte Antworten gehören nicht hinein. Die Antwort selbst wird zur Wartungsseite.
|
||||
*/
|
||||
final class ErrorLogSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private bool $debug = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach der Sicherheitsschicht (Priorität 1): „Zugriff verweigert“ ist dort eine
|
||||
// Weiterleitung zur Anmeldung, kein Notfall. Vor der Fehlerseite des Frameworks (-128),
|
||||
// damit der Eintrag auch dann entsteht, wenn sie selbst scheitert.
|
||||
return [KernelEvents::EXCEPTION => ['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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+183
-5
@@ -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<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
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/<lauf>/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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Die letzte Sicherung, wenn die Anwendung nicht einmal mehr eine Fehlerseite zustande bringt
|
||||
* (ADR 113).
|
||||
*
|
||||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach antwortete jede Adresse mit
|
||||
* einem leeren 500er - für Besucher eine weiße Seite, für den Betreiber kein Hinweis, warum. Der
|
||||
* Grund stand nur im Fehlerprotokoll des Hostings.
|
||||
*
|
||||
* Diese Klasse hängt an nichts aus dem Framework: kein Container, keine Datenbank, keine
|
||||
* Vorlage. Sie schreibt den Grund nach var/log/notfall.log (per FTP lesbar, außerhalb von
|
||||
* public/) und zeigt Besuchern eine schlichte Wartungsseite mit 503 - für Suchmaschinen heißt das
|
||||
* „gleich wieder da“, nicht „kaputt“. Details stehen nie auf der Seite.
|
||||
*/
|
||||
final class Notfall
|
||||
{
|
||||
public const PROTOKOLL = 'var/log/notfall.log';
|
||||
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach notfall.log.1. */
|
||||
private const GRENZE = 1048576;
|
||||
/** Fehlerarten, nach denen PHP abbricht - nur die gehören auf die Notfallseite. */
|
||||
private const ABBRUCH = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
|
||||
|
||||
public const KOPFZEILEN = [
|
||||
'Content-Type' => '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 '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
.'<title>Gleich wieder da</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:17px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#f4f6f5;color:#1f2d27}'
|
||||
.'main{max-width:460px;padding:32px;text-align:center}h1{font-size:26px;line-height:1.25;margin:0 0 12px}p{margin:0;color:#46514c}</style></head>'
|
||||
.'<body><main><h1>Die Website ist gleich wieder da.</h1><p>Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.</p>'
|
||||
.'<p lang="en" style="margin-top:14px;font-size:14px">This website will be back shortly.</p></main></body></html>';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'system' %}
|
||||
{% block title %}System{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/system-update.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}System und Update{% endblock %}
|
||||
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
|
||||
{% block content %}
|
||||
@@ -137,10 +138,17 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
{# 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. #}
|
||||
<div class="card update-card" id="update" data-update
|
||||
data-installiert="{{ state.version }}" data-csrf="{{ csrf_token('system') }}"
|
||||
data-pruefen="{{ path('admin_update_check') }}" data-status="{{ path('admin_update_status') }}"
|
||||
data-starten="{{ path('admin_update_start') }}" data-abschliessen="{{ path('admin_update_finish') }}">
|
||||
<div class="card-head">
|
||||
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span>
|
||||
<h2>Update</h2>
|
||||
<span class="muted">Installiert <strong data-update-installiert>{{ state.version }}</strong></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if archivePending %}
|
||||
@@ -150,35 +158,30 @@
|
||||
<input type="hidden" name="finish" value="1">
|
||||
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
|
||||
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
|
||||
<details class="hint">
|
||||
<summary>Was unberührt bleibt</summary>
|
||||
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
|
||||
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.</p>
|
||||
</details>
|
||||
{% if archiveVersion %}
|
||||
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
|
||||
{% elseif asked %}
|
||||
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
|
||||
{% else %}
|
||||
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – dauert einen Moment.</p>
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="archive-target">Fassung</label>
|
||||
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+">
|
||||
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p>
|
||||
<div class="update-live" data-update-live role="status" aria-live="polite" hidden></div>
|
||||
<details class="hint update-manual">
|
||||
<summary>Weitere Möglichkeiten</summary>
|
||||
<p class="field-help">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungsseite an, Dateien tauschen, Datenbank anpassen, prüfen. Unberührt bleiben <code>var/</code>, <code>.env</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
|
||||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="archive-target">Bestimmte Fassung</label>
|
||||
<input id="archive-target" name="target" maxlength="20" placeholder="1.4.2" pattern="[0-9]+\.[0-9]+\.[0-9]+">
|
||||
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung. Dieser Weg funktioniert auch ohne JavaScript.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button class="secondary" type="submit">Update aus dem Archiv einspielen</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="update-dialog" class="update-dialog" aria-labelledby="update-dialog-title">
|
||||
<h2 id="update-dialog-title">Auf <span data-update-ziel></span> aktualisieren?</h2>
|
||||
<p>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.</p>
|
||||
<div class="dialog-actions"><button type="button" class="secondary" data-update-abbrechen>Abbrechen</button><button type="button" class="primary" data-update-los>Update starten</button></div>
|
||||
</dialog>
|
||||
|
||||
{% if state.worktree %}
|
||||
<div class="card">
|
||||
|
||||
@@ -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("<?php // alt\n", file_get_contents($this->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' => "<?php // neu\n",
|
||||
'config/services.yaml' => "services:\n",
|
||||
'templates/base.html.twig' => 'neu',
|
||||
'migrations.php' => "<?php return ['neu'];\n",
|
||||
'migrations.php' => "<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n",
|
||||
'migrations/.keep' => '',
|
||||
'cli-config.php' => "<?php // neu\n",
|
||||
'composer.json' => '{"name":"neu"}',
|
||||
'composer.lock' => '{"packages":["neu"]}',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\EventSubscriber\ErrorLogSubscriber;
|
||||
use App\Ops\Notfall;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Kein leerer 500er mehr (ADR 113).
|
||||
*
|
||||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach eine weiße Seite auf jeder
|
||||
* Adresse und kein Hinweis darauf, warum. Seitdem sehen Besucher eine Wartungsseite mit 503, und
|
||||
* der Grund steht in var/log/notfall.log.
|
||||
*/
|
||||
final class NotfallTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user