Files
KantSites/src/Ops/SitePackage.php
T
kantarciandClaude Opus 5.5 32e42f973b Move site content between installations as a package
System now exports a content package and imports one, by upload or from
var/pakete. Pages with their revisions, areas, menus, media, forms,
redirects, terms, translations, design and settings are replaced;
submissions, statistics, tasks, plugin state and service keys stay, and
accounts only come along when that is chosen explicitly. A backup comes
first, the site is in maintenance meanwhile, and every file and document
in the package is checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:31:06 +02:00

487 lines
21 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', '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)|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();
}
$this->updater->maintenance(true);
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
$moved = $this->replaceMedia($media);
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
return $fail();
} finally {
$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));
}
}
}
}
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;
}
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
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.');
}
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name)) {
@unlink($this->mediaDirectory.'/'.$name);
}
}
$count = 0;
foreach (scandir($source) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name) && @copy($source.'/'.$name, $this->mediaDirectory.'/'.$name)) {
++$count;
}
}
return $count;
}
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);
}
}