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} */ 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} */ /** * @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//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]; } }