Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

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:
kantarciandClaude Opus 5 committed 2026-09-13 23:01:32 +02:00
1 parent ea3dd4ddf2
commit d6d476177a
26 files changed
+1740 -26

No files matched your search

+334
View File
@@ -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);
}
}