Zwei Luecken, die erst das Veroeffentlichen von 1.4.3 gezeigt hat. - Es gab keinen Weg, ausstehende Migrationen im Browser auszufuehren. Wer die Dateien per FTP hochlaedt - und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt -, hatte danach neuen Code und eine alte Datenbank, und dafuer nur die Konsole. Also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm "System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und fuehrt sie auf Knopfdruck aus. - "Die Releaseseite antwortet nicht" waren drei Ursachen in einem Satz. Beim Gegenpruefen des frischen Releases holte der Updater von hier gar nichts - nicht weil die Leitung zu waere, sondern weil diesem PHP der Zertifikatsspeicher fehlt. Auf einem Webhosting ist das der haeufigste Grund und der einzige, den man in einer Minute behebt; er steht jetzt in der Meldung. Bei genau diesem Fehler wird zusaetzlich der Weg ueber den Dateizeiger versucht - damit funktioniert der Abruf auf diesem Rechner. Abgeschaltet wird die Pruefung nicht: ein Update ohne Zertifikatspruefung koennte unterwegs ausgetauscht worden sein. Dazu der manuelle Weg Schritt fuer Schritt in docs/UPDATE.md und ein Test, der beides festhaelt: dass die Karte auffaellt und dass nur ein angemeldeter Administrator mit gueltigem Formular migrieren kann. Geprueft: 431 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und Linkpruefung, Editor-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
14 KiB
PHP
335 lines
14 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'];
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private const MARKE = 'var/update/offen.json';
|
|
|
|
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 [
|
|
'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{label: string, ok: bool, detail: string}>}
|
|
*/
|
|
public function run(?string $version = null): array
|
|
{
|
|
$schritte = [];
|
|
$melden = static function (array &$schritte, string $label, bool $ok, string $detail = ''): bool {
|
|
$schritte[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
|
|
|
|
return $ok;
|
|
};
|
|
|
|
$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];
|
|
}
|
|
|
|
$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')));
|
|
} 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';
|
|
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
|
if (null === $inhalt || '' === $inhalt) {
|
|
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
|
|
|
|
return ['ok' => false, 'steps' => $schritte];
|
|
}
|
|
file_put_contents($archiv, $inhalt);
|
|
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 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->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.
|
|
$this->mergePublic($quelle.'/public', $this->root.'/public');
|
|
} 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));
|
|
|
|
// Der Zwischenspeicher gehört zum alten Stand.
|
|
$this->removeDirectory($this->root.'/var/cache');
|
|
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
|
|
|
// 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.
|
|
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
|
|
'fassung' => $ziel,
|
|
'seit' => gmdate('c'),
|
|
'alt' => basename($arbeit),
|
|
], JSON_UNESCAPED_SLASHES));
|
|
$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];
|
|
}
|
|
|
|
/**
|
|
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
|
*
|
|
* @return array{fassung: string, seit: string, alt: 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{label: string, ok: bool, detail: string}>}
|
|
*/
|
|
public function finish(Installation $installation): array
|
|
{
|
|
$schritte = [];
|
|
try {
|
|
$namen = $installation->migrate();
|
|
$schritte[] = ['label' => '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[] = ['label' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
|
|
|
return ['ok' => false, 'steps' => $schritte];
|
|
}
|
|
|
|
@unlink($this->root.'/'.self::MARKE);
|
|
$this->updater->maintenance(false);
|
|
$schritte[] = ['label' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
|
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
|
$this->updater->version(),
|
|
)];
|
|
|
|
return ['ok' => true, 'steps' => $schritte];
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
@copy($quelle, $ziel);
|
|
}
|
|
}
|
|
|
|
/** 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];
|
|
}
|
|
|
|
private function removeDirectory(string $pfad): void
|
|
{
|
|
if (!is_dir($pfad)) {
|
|
return;
|
|
}
|
|
foreach (scandir($pfad) ?: [] as $eintrag) {
|
|
if ('.' === $eintrag || '..' === $eintrag) {
|
|
continue;
|
|
}
|
|
$voll = $pfad.'/'.$eintrag;
|
|
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
|
}
|
|
@rmdir($pfad);
|
|
}
|
|
}
|