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