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>
This commit is contained in:
1 parent
a57b829a15
commit
32e42f973b
13 files changed
+973
-2
No files matched your search
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:paket:export', description: 'Writes a content package (pages, media, forms, design, settings) for import into another installation (ADR 118).')]
|
||||
final class PackageExportCommand extends Command
|
||||
{
|
||||
public function __construct(private SitePackage $packages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten mit Passwort-Hashes mitnehmen.')
|
||||
->addOption('ziel', null, InputOption::VALUE_REQUIRED, 'Pfad der Zip-Datei (Standard: var/pakete/paket-<website>-<zeit>.zip).');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$file = $this->packages->export((bool) $input->getOption('mit-benutzern'), $input->getOption('ziel') ?: null);
|
||||
} catch (InvalidPage $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$manifest = $this->packages->inspect($file);
|
||||
$io->success(sprintf('%s: %d Seite(n), %d Mediendatei(en), Konten %s.', $file, (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien'], $manifest['benutzer'] ? 'enthalten' : 'nicht enthalten'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'cms:paket:import', description: 'Replaces the content of this installation with a content package; accounts stay unless --mit-benutzern (ADR 118).')]
|
||||
final class PackageImportCommand extends Command
|
||||
{
|
||||
public function __construct(private SitePackage $packages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('datei', InputArgument::REQUIRED, 'Die Zip-Datei des Pakets.')
|
||||
->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten und Passwörter aus dem Paket übernehmen.')
|
||||
->addOption('ja', null, InputOption::VALUE_NONE, 'Ohne Rückfrage ersetzen.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$file = (string) $input->getArgument('datei');
|
||||
if (!$input->getOption('ja') && !$io->confirm('Die Inhalte dieser Installation werden ersetzt (vorher wird gesichert). Weiter?', false)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$result = $this->packages->import($file, (bool) $input->getOption('mit-benutzern'));
|
||||
foreach ($result['steps'] as $step) {
|
||||
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? 'ok ' : 'FEHLER', $step['step'], $step['detail']));
|
||||
}
|
||||
|
||||
return $result['ok'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\SitePackage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Inhaltspakete exportieren und importieren (ADR 118) - unter System, nur für Administratoren.
|
||||
*
|
||||
* Der Import ersetzt die Inhalte einer laufenden Website. Deshalb: CSRF, ein ausdrückliches
|
||||
* Häkchen, vorher eine Sicherung, während des Imports Wartung. Konten werden nur übernommen,
|
||||
* wenn man das eigens wählt - sonst bleiben Benutzer und Passwörter dieser Website.
|
||||
*/
|
||||
final class PackageController extends AbstractController
|
||||
{
|
||||
public function __construct(private SitePackage $packages, private FontLibrary $fonts, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/paket/export', name: 'admin_package_export', methods: ['POST'])]
|
||||
public function export(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);
|
||||
try {
|
||||
$file = $this->packages->export($request->request->getBoolean('benutzer'));
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
$response = new BinaryFileResponse($file, headers: ['Content-Type' => 'application/zip', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'no-store']);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, basename($file));
|
||||
// Ein Export ist zum Mitnehmen da, nicht zum Liegenlassen: er enthält alle Inhalte.
|
||||
$response->deleteFileAfterSend();
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/paket/import', name: 'admin_package_import', methods: ['POST'])]
|
||||
public function import(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.');
|
||||
}
|
||||
if (!$request->request->getBoolean('bestaetigt')) {
|
||||
return $this->back('Bitte bestätige, dass die Inhalte dieser Website ersetzt werden.');
|
||||
}
|
||||
$upload = $request->files->get('paket');
|
||||
$waiting = basename($request->request->getString('wartend'));
|
||||
$temporary = null;
|
||||
if ($upload instanceof UploadedFile) {
|
||||
if (!$upload->isValid()) {
|
||||
return $this->back(\in_array($upload->getError(), [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE], true)
|
||||
? 'Das Paket ist größer, als das Hosting für einen Upload erlaubt. Bitte per FTP nach var/pakete/ legen – dann erscheint es hier zur Auswahl.'
|
||||
: 'Das Hochladen ist gescheitert. Bitte erneut versuchen oder das Paket per FTP nach var/pakete/ legen.');
|
||||
}
|
||||
$temporary = $this->packages->directory().'/hochgeladen-'.bin2hex(random_bytes(6)).'.zip';
|
||||
@mkdir($this->packages->directory(), 0o775, true);
|
||||
$upload->move(\dirname($temporary), basename($temporary));
|
||||
$file = $temporary;
|
||||
} elseif ('' !== $waiting && str_ends_with($waiting, '.zip') && is_file($this->packages->directory().'/'.$waiting)) {
|
||||
$file = $this->packages->directory().'/'.$waiting;
|
||||
} else {
|
||||
return $this->back('Bitte ein Paket hochladen oder eines aus var/pakete/ wählen.');
|
||||
}
|
||||
|
||||
@set_time_limit(0);
|
||||
try {
|
||||
$result = $this->packages->import($file, $request->request->getBoolean('benutzer'));
|
||||
} finally {
|
||||
if (null !== $temporary) {
|
||||
@unlink($temporary);
|
||||
}
|
||||
}
|
||||
|
||||
// Das Ergebnis direkt, nicht über eine Weiterleitung: wurden die Konten ersetzt, gilt die
|
||||
// Anmeldung ab der nächsten Anfrage nicht mehr.
|
||||
// Schriften liegen nicht im Paket, sondern auf dem Server dieser Website (public/assets/fonts).
|
||||
// Fehlt eine, die die neue Gestaltung verlangt, sagt die Ergebnisseite, wo man sie holt.
|
||||
$missing = $result['ok'] ? $this->fonts->missing($this->theme->load()) : [];
|
||||
|
||||
return $this->render('admin/package-result.html.twig', ['result' => $result, 'fontsMissing' => $missing]);
|
||||
}
|
||||
|
||||
private function back(string $message): Response
|
||||
{
|
||||
$this->addFlash('error', $message);
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\SitePackage;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +48,9 @@ final class SystemController extends AbstractController
|
||||
'asked' => $request->query->getBoolean('pruefen'),
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
'backups' => $this->backup->all(),
|
||||
// Inhaltspakete (ADR 118): die per FTP abgelegten und die Grenze für Uploads.
|
||||
'packages' => $this->packages->waiting(),
|
||||
'uploadLimit' => $this->uploadLimit(),
|
||||
'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,
|
||||
@@ -497,6 +501,23 @@ final class SystemController extends AbstractController
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
/** Die kleinere der beiden PHP-Grenzen, die einen Upload begrenzen - so, wie sie in der php.ini steht. */
|
||||
private function uploadLimit(): string
|
||||
{
|
||||
$bytes = static function (string $value): int {
|
||||
$value = trim($value);
|
||||
$number = (int) $value;
|
||||
|
||||
return match (strtolower(substr($value, -1))) {
|
||||
'g' => $number * 1024 ** 3, 'm' => $number * 1024 ** 2, 'k' => $number * 1024, default => $number,
|
||||
};
|
||||
};
|
||||
$limits = array_filter([$bytes((string) ini_get('upload_max_filesize')), $bytes((string) ini_get('post_max_size'))]);
|
||||
$min = [] === $limits ? 0 : min($limits);
|
||||
|
||||
return 0 === $min ? 'unbegrenzt' : number_format($min / 1048576, 0, ',', '.').' MB';
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user