Files
KantSites/src/Ops/ArchiveUpdate.php
T
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00

662 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Ops;
/**
* Ein Update für Installationen ohne Git.
*
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
* genau der Fall, für den dieses CMS gedacht ist.
*
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
* einer Installation trennen:
*
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
* `var/update/` und lässt sich zurückschieben.
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
* niemand zu sehen bekommen.
*/
final class ArchiveUpdate
{
public const LAEUFT_BEREITS = 'Der Abschluss läuft bereits in einer anderen Anfrage. Diese Seite in einem Moment neu laden.';
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
/**
* Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet.
*
* Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver
* sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist.
* Gelöscht wird in finish(): Das Kopieren erledigt noch der Code der alten Fassung, der
* diese Liste nicht kennt.
*/
private const ENTFALLEN_IN_PUBLIC = ['favicon.svg'];
/**
* Die Marke zwischen den beiden Schritten.
*
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
*/
public const MARKE = 'var/update/offen.json';
/** 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,
private Backup $backup,
private Download $download,
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
) {
}
/**
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
*
* @return array{version: string, url: string, checksum: ?string}|null
*/
public function latest(): ?array
{
$antwort = $this->download->get($this->apiBase.'/releases/latest');
if (null === $antwort) {
return null;
}
try {
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
$tag = (string) ($daten['tag_name'] ?? '');
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
return null;
}
$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)),
];
}
/** Gibt es etwas Neueres als das, was hier läuft? */
public function updateAvailable(): ?string
{
$neueste = $this->latest();
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
? $neueste['version']
: null;
}
/**
* Spielt die angegebene (oder die neueste) Fassung ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function run(?string $version = null): array
{
$schritte = [];
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
//
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
// Einzige, was von einem gescheiterten Update übrig bleibt.
$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;
};
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
$neueste = $this->latest();
if (null === $neueste) {
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$ziel = $version ?? $neueste['version'];
// Die Fassung landet in Adresse und Pfaden; nur die Form 1.2.3 ist zulässig (ADR 116).
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $ziel)) {
$melden($schritte, 'Fassung ermitteln', false, 'Die gewünschte Fassung hat nicht die Form 1.2.3.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
if (version_compare($ziel, $this->updater->version(), '<=')) {
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
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) {
$melden($schritte, 'Sperre', false, 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.');
return ['ok' => false, 'steps' => $schritte];
}
try {
return $this->apply($schritte, $melden, $ziel, $neueste);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
private function apply(array &$schritte, callable $melden, string $ziel, array $neueste): array
{
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
return ['ok' => false, 'steps' => $schritte];
}
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
// Megabyte passte in memory_limit, ein groesseres nicht mehr.
if (!$this->download->toFile(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel), $archiv)) {
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round((int) filesize($archiv) / 1024)));
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
if (null === $erwartet) {
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
return ['ok' => false, 'steps' => $schritte];
}
$tatsaechlich = hash_file('sha256', $archiv);
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
$entpackt = $arbeit.'/neu';
$zip = new \ZipArchive();
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
return ['ok' => false, 'steps' => $schritte];
}
$zip->close();
$quelle = $entpackt.'/kantsites-'.$ziel;
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
return ['ok' => false, 'steps' => $schritte];
}
$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.');
// Erst sichern, dann anfassen - aber erst jetzt, hinter der Wartungsseite. Vorher entstand
// die Sicherung vor dem Laden und Entpacken: Was in diesen Sekunden bis Minuten noch
// hereinkam (eine Anfrage über ein Formular, ein gespeicherter Entwurf), fehlte in ihr und
// war nach einem Zurückschalten weg.
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')));
} catch (\Throwable $fehler) {
$this->updater->maintenance(false);
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
try {
foreach (self::ERSETZEN as $eintrag) {
$ziant = $this->root.'/'.$eintrag;
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
}
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
}
++$ersetzt;
}
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// 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)) {
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
}
}
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
$this->updater->maintenance(false);
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
// Never delete compiled services underneath the running kernel. The new kernel uses
// a release-specific cache directory; old requests can finish with their own files.
$melden($schritte, 'Zwischenspeicher', true, 'Die neue Fassung verwendet einen eigenen Cache; laufende Anfragen behalten ihren bisherigen.');
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
// Wartungszustand bleibt so lange an.
// Ein Einmalschlüssel für den Abschluss: Die Seite, die diese Anfrage noch ausgibt, ruft
// damit den zweiten Schritt selbst auf - ohne Knopf, und ohne dass eine fremde Seite ihn
// auslösen könnte (dazu braucht es zusätzlich eine Anmeldung als Administrator).
$token = bin2hex(random_bytes(16));
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
'fassung' => $ziel,
'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,
));
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
}
/**
* Kennzeichnet den offenen zweiten Schritt als von der Zentrale angestoßen (ADR 123).
*
* Dann wartet niemand in der Verwaltung, der ihn aufruft; die nächste Anfrage mit dem neuen
* Code schließt ihn ab (@see RemoteUpdateFinishSubscriber).
*/
public function markiereFern(): void
{
$offen = $this->pending();
if (null === $offen) {
return;
}
@file_put_contents($this->root.'/'.self::MARKE, json_encode([...$offen, 'fern' => true], JSON_UNESCAPED_SLASHES));
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
* @return array{fassung: string, seit: string, alt: string, token?: string}|null
*/
public function pending(): ?array
{
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
if (!is_string($inhalt)) {
return null;
}
try {
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
}
/**
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
*
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
/**
* @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, bool $gesperrt = false): array
{
// Nur ein Abschluss zur selben Zeit. Die Zwischenseite ruft ihn von selbst auf und bietet
// daneben einen Link an: Zwei Abschlüsse nebeneinander ließen den zweiten an der bereits
// laufenden Migration scheitern - und der schaltete dann zurück, Code und Datenbank,
// obwohl der erste gelungen war. $gesperrt: der Aufrufer hält die Sperre schon.
$sperre = $gesperrt ? null : $this->updater->lock();
if (!$gesperrt && null === $sperre) {
return ['ok' => false, 'laeuft' => true, 'steps' => [['step' => 'Abschluss', 'ok' => true, 'detail' => self::LAEUFT_BEREITS]]];
}
try {
return $this->abschliessen($installation, $pruefen);
} finally {
if (null !== $sperre) {
flock($sperre, LOCK_UN);
fclose($sperre);
}
}
}
/** @param (callable(): ?string)|null $pruefen */
private function abschliessen(Installation $installation, ?callable $pruefen): 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()];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
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);
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
@unlink($this->root.'/public/'.$eintrag);
}
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
'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.
*
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
* schreibbar ist, wäre absurd.
*/
private function protokoll(string $zeile): void
{
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
}
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
private function mergePublic(string $von, string $nach): void
{
if (!is_dir($von)) {
return;
}
foreach (scandir($von) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$quelle = $von.'/'.$eintrag;
$ziel = $nach.'/'.$eintrag;
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
continue;
}
if (is_dir($quelle)) {
@mkdir($ziel, 0o775, true);
$this->mergePublic($quelle, $ziel);
continue;
}
$this->merken($ziel);
if ('.htaccess' === $eintrag && is_file($ziel)) {
$this->mergeHtaccess($quelle, $ziel);
continue;
}
@copy($quelle, $ziel);
}
}
/** 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
* Zeilen des Releases zurueck, und die Website lief nach dem Update wieder ohne Umleitung.
*/
private function mergeHtaccess(string $neu, string $alt): void
{
file_put_contents($alt, HttpsRedirect::keepEnabled((string) file_get_contents($neu), (string) file_get_contents($alt)));
}
/**
* Raeumt nach einem gelungenen Update auf: die Arbeitsordner unter var/update/ bis auf die
* zwei juengsten, und die Zwischenspeicher frueherer Fassungen unter var/cache/. Beides wuchs
* vorher mit jedem Update, bis der Speicherplatz des Hostings voll war.
*/
private function aufraeumen(): string
{
$entfernt = 0;
$ordner = glob($this->root.'/var/update/*', GLOB_ONLYDIR) ?: [];
rsort($ordner);
foreach (array_slice($ordner, 2) as $pfad) {
$this->loeschen($pfad);
++$entfernt;
}
$aktuell = 'release-'.substr(hash_file('sha256', $this->root.'/VERSION') ?: '', 0, 16);
foreach (glob($this->root.'/var/cache/*/release-*', GLOB_ONLYDIR) ?: [] as $pfad) {
if (basename($pfad) !== $aktuell) {
$this->loeschen($pfad);
++$entfernt;
}
}
return 0 === $entfernt ? 'Nichts aufzuräumen.' : sprintf('%d alte Ordner unter var/update und var/cache entfernt.', $entfernt);
}
private function loeschen(string $pfad): void
{
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
is_dir($pfad.'/'.$eintrag) && !is_link($pfad.'/'.$eintrag) ? $this->loeschen($pfad.'/'.$eintrag) : @unlink($pfad.'/'.$eintrag);
}
@rmdir($pfad);
}
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
private function checksumOf(string $url): ?string
{
$inhalt = $this->download->get($url);
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
return null;
}
return $treffer[1];
}
}