Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
Vier Wuensche und ein Fund beim Nachmessen. - Farbwaehler im Designbereich. Das Textfeld bleibt massgeblich, damit "@akzent" ein Verweis auf die Palette bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). - Die Verwaltung auf dem Telefon: das Menue klappt unter 760 px zu und nennt den Bereich, in dem man steht; Ziele 44 px, kein Skript. - Eigene SVG-Symbole statt Unicode-Zeichen - ein Telefon ersetzt die sonst durch Emoji in fremdem Stil. Zwei Eintraege trugen dabei denselben Pfad; ein Test findet beides (ADR 067). - Update aus dem Releasearchiv: der Weg fuer Installationen ohne Git. Zwei Schritte, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. var/, .env, hochgeladene Schriften und das erzeugte Buendel bleiben unberuehrt; public/ wird dazu Datei fuer Datei zusammengefuehrt. Geprueft ohne Netz gegen eine Releaseseite im Speicher (ADR 068). Beim Nachmessen der echten Startseite statt einer Testseite: das groesste Element ueber der Falz ist dort kein <img>, sondern der Hintergrund des ersten Abschnitts - und der steht nur im Stylesheet. Drei Vorabverweise mit denselben Geraetegrenzen wie der Compiler holen genau die Variante, die die Regel auch nimmt. LCP 1,3 s -> 1,1 s. Dabei gefunden: dasselbe Bild wurde je Geraeteregel neu abgefragt - Startseite 19 -> 15 Abfragen (ADR 069). Geprueft: 428 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und Linkpruefung, Lighthouse auf drei echten Seiten (alles ausser der Leistung der Startseite 100). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea3dd4ddf2
commit
d6d476177a
26 files changed
+1740
-26
No files matched your search
@@ -14,6 +14,13 @@ final class MediaLibrary
|
||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||
public const WIDTHS = [480, 960, 1600, 2400];
|
||||
|
||||
/**
|
||||
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||
*
|
||||
* @var array<string, ?array>
|
||||
*/
|
||||
private array $gemerkt = [];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -53,11 +60,22 @@ final class MediaLibrary
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
|
||||
*
|
||||
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
|
||||
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
|
||||
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
|
||||
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
|
||||
*/
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (array_key_exists($id, $this->gemerkt)) {
|
||||
return $this->gemerkt[$id];
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +181,7 @@ final class MediaLibrary
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('media', $row);
|
||||
$this->gemerkt = [];
|
||||
|
||||
return $this->present($row);
|
||||
} catch (\Throwable $error) {
|
||||
@@ -183,6 +202,7 @@ final class MediaLibrary
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
||||
unset($this->gemerkt[$id]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,18 @@ final class StyleCompiler
|
||||
{
|
||||
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
||||
|
||||
/**
|
||||
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
|
||||
*
|
||||
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
|
||||
* holte ein Vorabverweis ein zweites Bild.
|
||||
*/
|
||||
public const PRELOAD_DEVICES = [
|
||||
'desktop' => '(min-width:1025px)',
|
||||
'tablet' => '(min-width:768px) and (max-width:1024px)',
|
||||
'mobile' => '(max-width:767px)',
|
||||
];
|
||||
|
||||
public function __construct(private ?MediaLibrary $media = null)
|
||||
{
|
||||
}
|
||||
@@ -226,7 +238,7 @@ final class StyleCompiler
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
// srcset, but the rules are already written per device, so the device decides.
|
||||
$largest = $this->variantFor($item['variants'], $device);
|
||||
$largest = self::variantFor($item['variants'], $device);
|
||||
// The URL comes from the media library, never from the document.
|
||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||
}
|
||||
@@ -356,7 +368,17 @@ final class StyleCompiler
|
||||
return $style;
|
||||
}
|
||||
|
||||
private function variantFor(array $variants, string $device): array
|
||||
/**
|
||||
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
|
||||
*
|
||||
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
|
||||
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
|
||||
*
|
||||
* @param list<array{src: string, width: int}> $variants
|
||||
*
|
||||
* @return array{src: string, width: int}
|
||||
*/
|
||||
public static function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
|
||||
@@ -106,6 +106,9 @@ final class PageController extends AbstractController
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
@@ -539,6 +542,49 @@ final class PageController extends AbstractController
|
||||
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
|
||||
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
|
||||
*/
|
||||
/**
|
||||
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
|
||||
*
|
||||
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
|
||||
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
|
||||
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
|
||||
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
|
||||
*
|
||||
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
|
||||
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
|
||||
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
|
||||
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
|
||||
*
|
||||
* @return list<array{src: string, media: string}>
|
||||
*/
|
||||
private function heroBackground(?array $header, array $document): array
|
||||
{
|
||||
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
|
||||
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||
foreach ([$header, $document] as $kandidat) {
|
||||
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
$verweise = [];
|
||||
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||
$verweise[] = [
|
||||
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||
'media' => $media,
|
||||
];
|
||||
}
|
||||
|
||||
return $verweise;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function firstImage(array ...$documents): ?string
|
||||
{
|
||||
foreach ($documents as $document) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -16,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -41,6 +42,8 @@ final class SettingsController extends AbstractController
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private string $adminPath)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,9 +41,50 @@ final class SystemController extends AbstractController
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
'backups' => $this->backup->all(),
|
||||
'adminPath' => $this->adminPath,
|
||||
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||||
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||||
'archivePending' => $this->archive->pending(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
|
||||
*
|
||||
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
|
||||
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
|
||||
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
|
||||
public function archive(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
if ($request->request->getBoolean('finish')) {
|
||||
$ergebnis = $this->archive->finish($this->installation);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
|
||||
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
if ($ergebnis['migrate'] ?? false) {
|
||||
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Es war nichts einzuspielen.'
|
||||
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?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, 'Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist.');
|
||||
|
||||
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, 'Das Archiv ließ sich nicht herunterladen. Darf dieser Server nach außen verbinden?');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Holt eine Datei über HTTPS - über curl, ersatzweise über den Dateizeiger.
|
||||
*
|
||||
* Zwei Wege, weil auf einem Webhosting mal das eine da ist und mal das andere: curl fehlt auf
|
||||
* manchen Paketen, `allow_url_fopen` ist auf anderen abgeschaltet. Dieselbe Doppelung steht im
|
||||
* Installer und beim Laden der Schriften.
|
||||
*
|
||||
* Eine eigene Klasse ist das aus zwei Gründen: Die Prüfung des Zertifikats und die Sperre für
|
||||
* alles außer HTTPS stehen an genau einer Stelle - und der Updater lässt sich prüfen, ohne dass
|
||||
* ein Test ins Netz geht.
|
||||
*/
|
||||
class Download
|
||||
{
|
||||
public function __construct(
|
||||
private int $connectTimeout = 15,
|
||||
private int $timeout = 900,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
|
||||
// kommt, kann unterwegs ausgetauscht worden sein.
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
return null;
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_USERAGENT => 'KantSites-Updater',
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
|
||||
return is_string($antwort) && 200 === $status ? $antwort : null;
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
return null;
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
|
||||
return is_string($antwort) ? $antwort : null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user