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:
kantarciandClaude Opus 5.5 committed 2026-10-12 16:31:06 +02:00
1 parent a57b829a15
commit 32e42f973b
13 files changed
+973 -2

No files matched your search

+104
View File
@@ -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');
}
}
+22 -1
View File
@@ -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);