- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
627 lines
30 KiB
PHP
627 lines
30 KiB
PHP
<?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'];
|
||
// 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];
|
||
}
|
||
|
||
// 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];
|
||
}
|
||
}
|