Files
KantSites/src/Ops/ArchiveUpdate.php
T
kantarciandClaude Opus 5.5 a6a334fe68 Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503.
The system page checks for a new release on load, shows its notes, runs
backup, download, switch, migration and health check in one flow with live
progress, and switches back automatically when the new version fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:33:53 +02:00

621 lines
29 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
{
/** 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'];
$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];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
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.');
return ['ok' => false, 'steps' => $schritte];
}
$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.');
$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];
}
/**
* 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): 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
{
$vorher = (string) file_get_contents($alt);
$inhalt = (string) file_get_contents($neu);
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
if ($aktiv) {
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
}
}
file_put_contents($alt, $inhalt);
}
/**
* 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];
}
}