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');
}
}