Files
KantSites/src/Ops/SitePackage.php
T
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00

524 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Ops;
use App\Content\DocumentValidator;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Inhalte einer Website als Paket - exportieren und in eine andere Installation importieren
* (ADR 118).
*
* Anlass: Eine lokal aufgebaute Website wurde bisher als ganze Datenbankdatei hochgeladen. Damit
* kamen auch die Konten mit, und nach jeder Änderung galt live ein neues Passwort; was nur live
* entstanden war (Anfragen, Statistik), war weg. Ein Paket trägt deshalb nur Inhalte: Seiten mit
* allen Fassungen, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen,
* Gestaltung und Einstellungen. Konten nur, wenn man sie ausdrücklich mitnimmt - und beim Import
* werden sie nur übernommen, wenn man das ausdrücklich wählt.
*
* Das Format ist Zip mit JSON statt einer SQLite-Datei: So geht ein Paket von SQLite nach MySQL
* und zurück, und der Import prüft jede Zeile, bevor er schreibt.
*/
final class SitePackage
{
public const FORMAT = 1;
public const MANIFEST = 'paket.json';
public const DATA = 'inhalt.json';
public const MEDIA = 'medien/';
/** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'video', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
/** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */
public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret'];
/** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf|mp4|webm)|favicon-\d{1,4}\.png)$/D';
private const MAX_FILES = 5000;
private const MAX_FILE_BYTES = 64 * 1024 * 1024;
private const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
private const MAX_DATA_BYTES = 64 * 1024 * 1024;
public function __construct(
private Connection $db,
private DocumentValidator $validator,
private Backup $backup,
private Updater $updater,
private string $root,
private string $mediaDirectory,
) {
}
/** Wohin Pakete gehören, die per FTP kommen, und wo Exporte entstehen. */
public function directory(): string
{
return $this->root.'/var/pakete';
}
/**
* Schreibt ein Paket und gibt seinen Pfad zurück.
*
* @throws InvalidPage
*/
public function export(bool $withUsers = false, ?string $target = null): string
{
if (!class_exists(\ZipArchive::class)) {
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket schreiben.');
}
$tables = [];
foreach (self::CONTENT_TABLES as $table) {
$tables[$table] = $this->rows($table);
}
$website = $this->db->fetchAssociative('SELECT id, name, default_locale, theme, settings FROM website WHERE id = ?', ['default']);
if (false === $website) {
throw new InvalidPage('Diese Installation hat noch keine Website.');
}
// Zugangsdaten zu Diensten bleiben, wo sie sind - auch im Paket stehen sie nicht.
$settings = json_decode((string) ($website['settings'] ?? '{}'), true) ?: [];
foreach (self::KEEP_SETTINGS as $key) {
unset($settings[$key]);
}
$website['settings'] = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tables['website'] = [$website];
if ($withUsers) {
$tables['cms_user'] = $this->rows('cms_user');
}
$data = json_encode(['tabellen' => $tables], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$files = [];
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name) && is_file($this->mediaDirectory.'/'.$name)) {
$files[] = $name;
}
}
$manifest = [
'format' => self::FORMAT,
'art' => 'KantSites-Inhaltspaket',
'fassung' => $this->backup->version(),
'erstellt' => gmdate('c'),
'website' => (string) $website['name'],
'benutzer' => $withUsers,
'inhalt' => array_map('count', $tables),
'medien' => count($files),
'sha256' => hash('sha256', $data),
];
$target ??= $this->directory().'/'.$this->fileName((string) $website['name']);
if (!is_dir(\dirname($target)) && !@mkdir(\dirname($target), 0o775, true)) {
throw new InvalidPage('Der Ordner für Pakete ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.');
}
$zip = new \ZipArchive();
if (true !== $zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new InvalidPage('Das Paket ließ sich nicht anlegen.');
}
$zip->addFromString(self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$zip->addFromString(self::DATA, $data);
foreach ($files as $name) {
$zip->addFile($this->mediaDirectory.'/'.$name, self::MEDIA.$name);
}
$zip->close();
return $target;
}
/** Pakete, die per FTP in var/pakete liegen - neueste zuerst. @return list<array{name: string, bytes: int, manifest: ?array}> */
public function waiting(): array
{
$found = [];
foreach (glob($this->directory().'/*.zip') ?: [] as $file) {
try {
$manifest = $this->inspect($file);
} catch (InvalidPage) {
$manifest = null;
}
$found[] = ['name' => basename($file), 'bytes' => (int) filesize($file), 'manifest' => $manifest, 'time' => (int) filemtime($file)];
}
usort($found, static fn (array $a, array $b): int => $b['time'] <=> $a['time']);
return $found;
}
/**
* Prüft ein Paket, ohne etwas zu schreiben, und gibt sein Manifest zurück.
*
* @throws InvalidPage
*/
public function inspect(string $file): array
{
$zip = $this->open($file);
try {
return $this->checkedManifest($zip);
} finally {
$zip->close();
}
}
/**
* Spielt ein Paket ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>, users: bool}
*/
public function import(string $file, bool $withUsers = false): array
{
$steps = [];
$step = static function (string $name, bool $ok, string $detail) use (&$steps): bool {
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
return $ok;
};
$fail = static function () use (&$steps): array {
return ['ok' => false, 'steps' => $steps, 'users' => false];
};
try {
$zip = $this->open($file);
} catch (InvalidPage $e) {
$step('Paket öffnen', false, $e->getMessage());
return $fail();
}
try {
try {
$manifest = $this->checkedManifest($zip);
$step('Paket prüfen', true, sprintf('„%s“ vom %s, Fassung %s, %d Seite(n), %d Mediendatei(en).', $manifest['website'], substr((string) $manifest['erstellt'], 0, 10), $manifest['fassung'], (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien']));
$tables = $this->checkedData($zip, $manifest);
$this->validateDocuments($tables);
$step('Inhalte prüfen', true, 'Alle Tabellen bekannt, alle Seiten- und Bereichsdokumente gültig.');
} catch (InvalidPage|InvalidDocument $e) {
$step('Paket prüfen', false, $e->getMessage());
return $fail();
}
if ($withUsers && [] === ($tables['cms_user'] ?? [])) {
$step('Konten', false, 'Das Paket enthält keine Konten. Ohne Häkchen bei „Konten übernehmen“ bleiben die Konten dieser Website.');
return $fail();
}
try {
$safety = $this->backup->create('vor Import');
$step('Aktuellen Stand sichern', true, $safety['name'].' – damit lässt sich der Import zurücknehmen (cms:restore).');
} catch (\Throwable $e) {
$step('Aktuellen Stand sichern', false, $e->getMessage().' Ohne Sicherung wird nicht importiert.');
return $fail();
}
// Eine Wartung, die schon vorher an war, hat jemand mit Absicht eingeschaltet - die
// hebt ein Import nicht auf.
$inWartung = is_file($this->root.'/'.Updater::MAINTENANCE_FILE);
$this->updater->maintenance(true);
try {
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
return $fail();
}
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
// Ab hier ist die Datenbank die des Pakets. Scheitern die Medien, sagt die Meldung
// genau das - und nennt die Sicherung, mit der sich beides zurücknehmen lässt.
try {
$moved = $this->replaceMedia($media);
} catch (\Throwable $e) {
$step('Medien ersetzen', false, sprintf('%s Die Inhalte sind bereits die des Pakets, die Medien nicht vollständig. Zurück auf den Stand vorher: cms:restore %s.', $e->getMessage(), $safety['name']));
return $fail();
}
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
} finally {
if (!$inWartung) {
$this->updater->maintenance(false);
}
if (isset($media)) {
$this->remove($media);
}
}
} finally {
$zip->close();
}
return ['ok' => true, 'steps' => $steps, 'users' => $withUsers];
}
/** @throws InvalidPage */
private function open(string $file): \ZipArchive
{
if (!class_exists(\ZipArchive::class)) {
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket lesen.');
}
$zip = new \ZipArchive();
if (!is_file($file) || true !== $zip->open($file, \ZipArchive::RDONLY)) {
throw new InvalidPage('Das ist kein lesbares Zip-Archiv.');
}
// Jeder Eintrag wird geprüft, bevor irgendetwas gelesen wird: nur die drei bekannten
// Arten, keine Pfade nach oben, keine übergroßen Dateien (Zip-Bombe).
if ($zip->numFiles > self::MAX_FILES + 2) {
$zip->close();
throw new InvalidPage('Das Paket enthält zu viele Dateien.');
}
$total = 0;
for ($i = 0; $i < $zip->numFiles; ++$i) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
$total += (int) ($stat['size'] ?? 0);
$known = self::MANIFEST === $name || self::DATA === $name || self::MEDIA === $name
|| (str_starts_with($name, self::MEDIA) && 1 === preg_match(self::MEDIA_NAME, substr($name, \strlen(self::MEDIA))));
if (!$known || (int) ($stat['size'] ?? 0) > self::MAX_FILE_BYTES) {
$zip->close();
throw new InvalidPage(sprintf('„%s“ gehört nicht in ein Inhaltspaket. Es wird nicht eingespielt.', mb_substr($name, 0, 80)));
}
}
if ($total > self::MAX_TOTAL_BYTES) {
$zip->close();
throw new InvalidPage('Das Paket ist entpackt größer als 1 GB.');
}
return $zip;
}
/** @throws InvalidPage */
private function checkedManifest(\ZipArchive $zip): array
{
$raw = $zip->getFromName(self::MANIFEST);
$manifest = false === $raw ? null : json_decode($raw, true);
if (!\is_array($manifest) || ($manifest['art'] ?? null) !== 'KantSites-Inhaltspaket' || ($manifest['format'] ?? null) !== self::FORMAT) {
throw new InvalidPage('Das ist kein KantSites-Inhaltspaket (paket.json fehlt oder passt nicht).');
}
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', (string) ($manifest['fassung'] ?? ''))) {
throw new InvalidPage('Im Paket fehlt die Fassung, aus der es stammt.');
}
// Ein Paket aus einer neueren Fassung kann Inhalte tragen, die dieser Kern nicht kennt.
if (version_compare((string) $manifest['fassung'], $this->backup->version(), '>')) {
throw new InvalidPage(sprintf('Das Paket stammt aus Fassung %s, installiert ist %s. Erst diese Website aktualisieren.', $manifest['fassung'], $this->backup->version()));
}
return $manifest;
}
/**
* @return array<string, list<array<string, mixed>>>
*
* @throws InvalidPage
*/
private function checkedData(\ZipArchive $zip, array $manifest): array
{
$stat = $zip->statName(self::DATA);
if (false === $stat || $stat['size'] > self::MAX_DATA_BYTES) {
throw new InvalidPage('inhalt.json fehlt oder ist zu groß.');
}
$raw = (string) $zip->getFromName(self::DATA);
if (!hash_equals((string) ($manifest['sha256'] ?? ''), hash('sha256', $raw))) {
throw new InvalidPage('Die Prüfsumme von inhalt.json stimmt nicht. Das Paket ist beschädigt oder verändert.');
}
$data = json_decode($raw, true, 32);
$tables = \is_array($data) ? ($data['tabellen'] ?? null) : null;
if (!\is_array($tables)) {
throw new InvalidPage('inhalt.json ist nicht lesbar.');
}
$allowed = [...self::CONTENT_TABLES, 'website', 'cms_user'];
foreach ($tables as $table => $rows) {
if (!\in_array($table, $allowed, true) || !\is_array($rows) || !array_is_list($rows)) {
throw new InvalidPage(sprintf('Die Tabelle „%s“ gehört nicht in ein Inhaltspaket.', mb_substr((string) $table, 0, 40)));
}
foreach ($rows as $row) {
if (!\is_array($row) || array_is_list($row) && [] !== $row) {
throw new InvalidPage(sprintf('Eine Zeile der Tabelle „%s“ ist ungültig.', $table));
}
foreach ($row as $column => $value) {
if (1 !== preg_match('/^[a-z_][a-z0-9_]{0,63}$/D', (string) $column) || !(null === $value || \is_scalar($value))) {
throw new InvalidPage(sprintf('Eine Spalte der Tabelle „%s“ ist ungültig.', $table));
}
}
}
}
// Pakete von vor Schema v28 kennen die Tabelle noch nicht; sie sind trotzdem vollständig.
$tables['video'] ??= [];
foreach (self::CONTENT_TABLES as $table) {
if (!isset($tables[$table])) {
throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table));
}
}
if (1 !== \count($tables['website'] ?? []) || 'default' !== ($tables['website'][0]['id'] ?? null)) {
throw new InvalidPage('Im Paket fehlen die Angaben zur Website.');
}
return $tables;
}
/** Jede Fassung und jeder Bereich muss ein gültiges Dokument sein - kaputte Inhalte kommen nicht herein. @throws InvalidDocument|InvalidPage */
private function validateDocuments(array $tables): void
{
foreach (['revision', 'partial'] as $table) {
foreach ($tables[$table] as $row) {
$document = json_decode((string) ($row['document'] ?? ''), true);
if (!\is_array($document)) {
throw new InvalidPage(sprintf('Ein Dokument der Tabelle „%s“ ist kein JSON.', $table));
}
// Bereiche brauchen keine Hauptüberschrift, Seiten schon - wie beim Speichern.
$this->validator->validate($document, 'revision' === $table);
}
}
}
/** Ersetzt die Inhalte in einer Transaktion. */
private function write(array $tables, bool $withUsers): void
{
$this->db->transactional(function () use ($tables, $withUsers): void {
// Anfragen behalten ihr Formular: Die Kennung eines Formulars kann sich mit dem Paket
// ändern, die Adresse (slug) bleibt - über sie werden die Anfragen neu zugeordnet.
$slugs = [];
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
$slugs[$form['id']] = $form['slug'];
}
foreach (array_reverse(self::CONTENT_TABLES) as $table) {
$this->db->executeStatement('DELETE FROM '.$table);
}
foreach (self::CONTENT_TABLES as $table) {
$this->insert($table, $tables[$table]);
}
$this->mergeWebsite($tables['website'][0]);
if ($withUsers) {
$this->db->executeStatement('DELETE FROM cms_user');
$this->insert('cms_user', $tables['cms_user']);
}
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
foreach (array_keys($slugs, $form['slug'], true) as $old) {
$this->db->executeStatement('UPDATE form_submission SET form_id = ? WHERE form_id = ?', [$form['id'], $old]);
}
}
});
}
/** Nur Spalten, die es hier gibt: ein Paket einer älteren Fassung kennt neuere Spalten nicht. */
private function insert(string $table, array $rows): void
{
$columns = array_map(static fn ($column): string => strtolower($column->getName()), $this->db->createSchemaManager()->listTableColumns($table));
foreach ($rows as $row) {
$this->db->insert($table, array_intersect_key($row, array_flip($columns)));
}
}
private function mergeWebsite(array $row): void
{
$current = $this->db->fetchAssociative('SELECT settings FROM website WHERE id = ?', ['default']);
$settings = json_decode((string) ($row['settings'] ?? '{}'), true) ?: [];
$own = json_decode((string) ($current['settings'] ?? '{}'), true) ?: [];
foreach (self::KEEP_SETTINGS as $key) {
unset($settings[$key]);
if (\array_key_exists($key, $own)) {
$settings[$key] = $own[$key];
}
}
$values = ['name' => (string) ($row['name'] ?? ''), 'default_locale' => (string) ($row['default_locale'] ?? 'de'), 'theme' => (string) ($row['theme'] ?? '{}'), 'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
if (false === $current) {
$this->db->insert('website', ['id' => 'default', ...$values]);
} else {
$this->db->update('website', $values, ['id' => 'default']);
}
}
/** Entpackt die Medien in einen eigenen Ordner neben dem Medienordner. */
private function extractMedia(\ZipArchive $zip): string
{
$target = $this->root.'/var/pakete/entpackt-'.bin2hex(random_bytes(6));
if (!@mkdir($target, 0o775, true)) {
throw new \RuntimeException('Ein Arbeitsordner unter var/pakete ließ sich nicht anlegen.');
}
for ($i = 0; $i < $zip->numFiles; ++$i) {
$name = (string) $zip->getNameIndex($i);
$base = substr($name, \strlen(self::MEDIA));
if (!str_starts_with($name, self::MEDIA) || 1 !== preg_match(self::MEDIA_NAME, $base)) {
continue;
}
// Gelesen und selbst geschrieben, nicht extractTo: der Zielname ist geprüft, nicht der Eintrag.
$stream = $zip->getStream($name);
$out = false === $stream ? false : fopen($target.'/'.$base, 'wb');
if (false === $stream || false === $out) {
throw new \RuntimeException(sprintf('%s ließ sich nicht entpacken.', $base));
}
stream_copy_to_stream($stream, $out, self::MAX_FILE_BYTES);
fclose($stream);
fclose($out);
}
return $target;
}
/**
* Legt die Dateien des Pakets in den Medienordner und entfernt danach, was nicht dazugehört.
*
* In dieser Reihenfolge: Erst wenn jede Datei des Pakets angekommen ist, verschwinden die
* alten. Vorher wurde zuerst geleert und dann kopiert, ohne nachzuzählen - bei knappem
* Speicher meldete der Import Erfolg, und die Website verwies auf Bilder, die es nicht gab.
*
* @throws \RuntimeException wenn eine Datei nicht ankommt; der Medienordner hat dann noch alle alten Dateien
*/
private function replaceMedia(string $source): int
{
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.');
}
$neu = [];
foreach (scandir($source) ?: [] as $name) {
if (1 !== preg_match(self::MEDIA_NAME, $name)) {
continue;
}
$size = (int) filesize($source.'/'.$name);
$target = $this->mediaDirectory.'/'.$name;
// Verschieben braucht keinen zweiten Platz auf der Platte; über Laufwerksgrenzen
// hinweg bleibt das Kopieren.
if (!@rename($source.'/'.$name, $target) && !@copy($source.'/'.$name, $target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s ließ sich nicht ablegen. Freien Speicherplatz prüfen.', $name));
}
clearstatcache(true, $target);
if ($size !== (int) @filesize($target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s kam unvollständig an. Freien Speicherplatz prüfen.', $name));
}
$neu[$name] = true;
}
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (!isset($neu[$name]) && 1 === preg_match(self::MEDIA_NAME, $name)) {
@unlink($this->mediaDirectory.'/'.$name);
}
}
return \count($neu);
}
private function summary(array $tables, bool $withUsers): string
{
return sprintf(
'%d Seite(n), %d Fassung(en), %d Bereich(e), %d Mediendatei(en), %d Formular(e). Konten: %s. Anfragen und Statistik dieser Website bleiben.',
\count($tables['page']), \count($tables['revision']), \count($tables['partial']), \count($tables['media']) + \count($tables['document']), \count($tables['form']),
$withUsers ? 'aus dem Paket übernommen ('.\count($tables['cms_user']).')' : 'unverändert',
);
}
private function rows(string $table): array
{
return $this->db->fetchAllAssociative('SELECT * FROM '.$table);
}
private function fileName(string $name): string
{
$name = strtr(mb_strtolower($name), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', $name), '-') ?: 'website';
return 'paket-'.substr($slug, 0, 40).'-'.gmdate('Ymd-His').'.zip';
}
private function remove(string $path): void
{
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $this->remove($entry) : @unlink($entry);
}
@rmdir($path);
}
}