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

+3 -1
View File
@@ -188,6 +188,8 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
**Inhalte zwischen Installationen übertragen** (seit 1.32.0, ADR 118): System → „Inhalte übertragen“ exportiert ein Inhaltspaket (Zip) und importiert eines – per Upload oder aus `var/pakete/`. Ersetzt werden Seiten, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen, Statistik und Turnstile-Zugangsdaten bleiben, Konten nur mit eigenem Häkchen. Vorher wird gesichert. Auf der Konsole: `php bin/console cms:paket:export` und `php bin/console cms:paket:import <datei>`.
## Eigene 404-Seite (ab 1.8.0)
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
@@ -397,7 +399,7 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
+4
View File
@@ -118,6 +118,10 @@ services:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
App\Ops\SitePackage:
arguments:
$root: '%kernel.project_dir%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
# jedes Mal mitwaechst.
App\Ops\Scheduler:
+14
View File
@@ -2,6 +2,20 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 118 – Inhaltspakete: Export und Import ohne die Konten (12.10.2026)
Anlass: Umut: „Können wir sowas wie einen Importer einbauen, weil sich nach jeder Anpassung sonst das Passwort ändert? Wo man auswählen muss, ob Passwort/Benutzer überschrieben werden sollen. Gerne auch als Plugin.“ Bisher wurde eine lokal gebaute Website als ganze SQLite-Datei hochgeladen – samt Konten (neues Passwort nach jedem Neuaufbau) und ohne das, was nur live entstanden war (Anfragen, Statistik).
**Entscheidung: im Kern, nicht als Plugin.** Plugins führen bewusst keinen eigenen Servercode aus (ADR zum Pluginsystem; serverseitiger Code nur mit Isolation). Ein Import schreibt Datenbank und Medienordner – das gehört in den Kern, unter System, nur für Administratoren.
**Paket.** Zip mit `paket.json` (Art, Format 1, Fassung, Website, Konten ja/nein, Zahlen, SHA-256 von `inhalt.json`), `inhalt.json` (Zeilen je Tabelle) und `medien/` (Bildvarianten, PDFs, erzeugte Seitensymbole). JSON statt SQLite-Datei: Ein Paket geht von SQLite nach MySQL und zurück, und jede Zeile wird vor dem Schreiben geprüft. Inhalt: `page`, `revision`, `partial`, `menu`, `media`, `document`, `form`, `redirect`, `term`, `page_term`, `page_translation` und die Website (Name, Sprache, Gestaltung, Einstellungen). **Nicht** darin: Anfragen, Statistik, Aufgaben, Plugin-Stand, Migrationsliste und die Zugangsdaten zu Diensten (`turnstile`, `turnstileSiteKey`, `turnstileSecret`). Konten nur mit „Konten mitnehmen“ beim Export.
**Import (`App\Ops\SitePackage::import`).** Prüfen vor Schreiben: nur bekannte Einträge im Zip (keine Pfade nach oben, keine anderen Dateitypen, Größengrenzen gegen Zip-Bomben), Prüfsumme von `inhalt.json`, nur bekannte Tabellen und Spaltennamen, jedes Seiten- und Bereichsdokument durch `DocumentValidator`, Paket nicht aus einer neueren Fassung. Dann Sicherung (`vor Import`), Wartungszustand, Inhalte in einer Transaktion ersetzen (nur Spalten, die es hier gibt – ein älteres Paket passt so in ein neueres Schema), Website-Einstellungen übernehmen außer den Dienst-Zugangsdaten, Anfragen über die Formularadresse dem neuen Formular zuordnen, Medienordner ersetzen. **Konten nur mit eigenem Häkchen** („Konten und Passwörter aus dem Paket übernehmen“); ohne bleiben Benutzer und Passwörter der Website. Ein Häkchen bei einem Paket ohne Konten bricht ab, ohne etwas zu ändern.
**Oberfläche.** System → „Inhalte übertragen“: Export als Download (Datei wird nach dem Senden gelöscht), Import per Upload oder aus `var/pakete/` (für Pakete über der Upload-Grenze des Hostings, die angezeigt wird), Bestätigungshäkchen, Ergebnisseite mit den Schritten und einem Hinweis, falls Schriften der neuen Gestaltung auf dem Server fehlen. Konsole: `cms:paket:export [--mit-benutzern] [--ziel=…]`, `cms:paket:import <datei> [--mit-benutzern] [--ja]`.
**Folgen.** Das Zurückspielen einer Sicherung bleibt Konsolensache; der Import ist der Weg für Inhalte aus einer anderen Installation, mit eigener Sicherung davor. Reset-Skripte können ein Paket schreiben (zuerst `reset-huefner.sh` → `var/huefner-paket.zip`). Keine Migration.
## ADR 117 – Menü schließt beim Klick daneben, Netzwerk-Symbole, schlankere Symbole (12.10.2026)
Anlass: Umut: „Bei Mobilmenü, wenn es aufgeklappt ist, schließt es nicht, wenn man irgendwo anders hinklickt.“ Und für Hüfner: Facebook, Instagram und „Angebot anfragen“ als Menü-Buttons statt eigener Knöpfe im Kopf.
+8
View File
@@ -1,5 +1,13 @@
# Projektstatus
## Fassung 1.32.0: Inhaltspakete – Export und Import ohne die Konten (12.10.2026)
**Anlass:** Umut: Nach jedem Hochladen einer neu aufgebauten Datenbank galt live ein anderes Passwort; gewünscht ist ein Importer mit der Wahl, ob Benutzer/Passwort überschrieben werden („gerne auch als Plugin“).
**Neu im Kern (ADR 118):** System → „Inhalte übertragen“: Paket exportieren (Download, Konten nur mit Häkchen) und importieren (Upload oder aus `var/pakete/`, Upload-Grenze des Hostings angezeigt). Ersetzt werden Seiten mit Fassungen, Bereiche, Menüs, Medien/PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen (neu dem Formular zugeordnet), Statistik, Aufgaben, Plugin-Stand und Turnstile-Zugangsdaten bleiben; Konten nur mit eigenem Häkchen. Vorher Sicherung, währenddessen Wartung, jede Datei und jedes Dokument geprüft. Konsole: `cms:paket:export`, `cms:paket:import`. Im Kern statt als Plugin, weil Plugins keinen Servercode ausführen. `reset-huefner.sh` schreibt zuletzt `var/huefner-paket.zip`.
**Geprüft:** PHPUnit vollständig 665 grün (neu `SitePackageTest`: Import ersetzt Inhalte und behält Konten, Anfragen, Dienst-Zugangsdaten; Konten nur mit beiden Häkchen; neuere Fassung abgelehnt; fünf manipulierte Pakete ändern nichts – fremde Datei mit `../`, Skript als Medium, veränderter Inhalt, fremde Tabelle, ungültiges Dokument). `npm test` 41, `lint:twig`, php-cs-fixer. Durchlauf auf der Konsole: Hüfner exportiert und in eine veränderte Kopie importiert (Passwort, Anfrage, Turnstile blieben, Alpakaseite kam zurück, Medienordner ersetzt). Oberfläche in der lokalen Hüfner-Verwaltung: zu großer Upload mit Hinweis abgelehnt, „Konten übernehmen“ bei Paket ohne Konten abgebrochen, Import aus `var/pakete/` abgeschlossen, danach weiter angemeldet. `test:editor` grün inklusive axe der Systemseite – ein erster Lauf parallel zu PHPUnit scheiterte, der Wiederholungslauf allein bestand.
## Fassung 1.31.0: Menü schließt beim Klick daneben, Netzwerk-Symbole; Hüfner-Kopf (12.10.2026)
**Anlass:** Umut: aufgeklapptes Mobilmenü schließt nicht beim Klick daneben; bei Hüfner wirkt der Kopf auf dem iPad verloren (Menü-Knopf mittig), und Facebook, Instagram und „Angebot anfragen“ sollen ins Menü.
+4
View File
@@ -25,3 +25,7 @@ if [ -f "$root/var/cacert.pem" ]; then
else
"$php" bin/console cms:fonts:fetch
fi
# Das Inhaltspaket für die Live-Seite (ADR 118): dort unter System → Inhalte übertragen importieren.
# Konten bleiben dabei, wie sie live sind.
rm -f "$root/var/huefner-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/huefner-paket.zip"
+43
View File
@@ -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;
}
}
+43
View File
@@ -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;
}
}
+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);
+486
View File
@@ -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);
}
}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'system' %}
{% block title %}Import{% endblock %}
{% block heading %}{{ result.ok ? 'Import abgeschlossen' : 'Import nicht ausgeführt' }}{% endblock %}
{% block lead %}{{ result.ok ? 'Die Inhalte des Pakets stehen jetzt auf dieser Website.' : 'Es wurde nichts ersetzt – oder der Stand davor liegt als Sicherung bereit.' }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-head"><h2>Schritte</h2></div>
<div class="card-body">
<ul class="check-list">
{% for step in result.steps %}
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
<span><strong>{{ step.step }}</strong> <span class="visually-hidden">{{ step.ok ? 'erledigt' : 'fehlgeschlagen' }}:</span> {{ step.detail }}</span>
</li>
{% endfor %}
</ul>
{% if result.ok and result.users %}
<p class="notice" role="status">Die Konten kamen aus dem Paket. Ab der nächsten Seite gilt die Anmeldung mit den Zugangsdaten aus dem Paket.</p>
{% endif %}
{% if fontsMissing is not empty %}
<p class="notice" role="status">Die neue Gestaltung nutzt Schriften, die auf diesem Server noch fehlen. Unter <a href="{{ path('admin_theme') }}">Design</a> einmal „Schriften auf diesen Server holen“ drücken – bis dahin zeigt die Website die Ersatzschrift.</p>
{% endif %}
<p><a class="button-link" href="{{ path('admin_system') }}">Zurück zu System</a> · <a class="button-link" href="/" target="_blank" rel="noopener">Website ansehen</a></p>
</div>
</div>
{% endblock %}
+38
View File
@@ -234,6 +234,44 @@
</div>
{% endif %}
<div class="card" id="paket">
<div class="card-head"><h2>Inhalte übertragen</h2><span class="muted">Export und Import · var/pakete</span></div>
<div class="card-body">
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
<h3>Exportieren</h3>
<form action="{{ path('admin_package_export') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
<button class="secondary" type="submit">Paket herunterladen</button>
</form>
<h3>Importieren</h3>
<form action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label for="paket-datei">Paket hochladen (.zip)</label>
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
{% if packages is not empty %}
<fieldset class="field-group">
<legend>Oder ein Paket aus var/pakete/</legend>
{% for package in packages %}
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
<code>{{ package.name }}</code> – {% if package.manifest %}„{{ package.manifest.website }}“, Fassung {{ package.manifest.fassung }}, {{ package.manifest.inhalt.page|default(0) }} Seiten, {{ package.manifest.medien }} Dateien{% if package.manifest.benutzer %}, mit Konten{% endif %}{% else %}<span class="muted">kein gültiges Paket</span>{% endif %}
</label>
{% endfor %}
</fieldset>
{% endif %}
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten und Passwörter aus dem Paket übernehmen</label>
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
<button class="primary" type="submit">Paket importieren</button>
</form>
</div>
</div>
<div class="card">
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
<div class="card-body">
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\SitePackage;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Inhaltspakete zwischen zwei Installationen (ADR 118).
*
* Der Anlass war das Passwort, das sich nach jedem Hochladen einer Datenbank änderte. Deshalb steht
* der Test vorn, der das festhält: Inhalte kommen, Konten, Anfragen und Zugangsdaten zu Diensten
* bleiben - außer man nimmt die Konten ausdrücklich mit.
*/
final class SitePackageTest extends TestCase
{
/** @var array<string, array{root: string, db: Connection, package: SitePackage}> */
private array $sites = [];
protected function setUp(): void
{
foreach (['quelle', 'ziel'] as $name) {
$root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-paket-'.$name.'-'.bin2hex(random_bytes(5));
mkdir($root.'/var/media', 0o775, true);
file_put_contents($root.'/VERSION', "1.32.0\n");
$url = 'sqlite:///'.$root.'/var/cms.db';
$db = ConnectionFactory::create($url);
TestSchema::build($db);
$db->insert('website', ['id' => 'default', 'name' => 'Website '.$name, 'default_locale' => 'de', 'theme' => '{}', 'settings' => json_encode(['turnstileSecret' => 'geheim-'.$name, 'turnstileSiteKey' => 'schluessel-'.$name])]);
$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db, new MediaLibrary($db, $root.'/var/media')));
$pages->createPage('ziel' === $name ? 'alte-seite' : 'neue-seite', null, 'page', DemoContent::starter('Seite der '.$name));
(new FormRepository($db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'info@example.org'], 0);
$db->insert('cms_user', ['id' => 'u-'.$name, 'username' => $name, 'password_hash' => 'hash-'.$name, 'roles' => '["ROLE_ADMIN"]']);
file_put_contents($root.'/var/media/'.str_repeat('ziel' === $name ? 'b' : 'a', 32).'-480.webp', 'bild '.$name);
$runner = new CommandRunner();
$this->sites[$name] = ['root' => $root, 'db' => $db, 'package' => new SitePackage($db, new DocumentValidator(), new Backup($root, $url, $root.'/var/media', $db, $runner), new Updater($root, $url, $runner, $db), $root, $root.'/var/media')];
}
// Eine Anfrage, die es nur auf der Zielwebsite gibt - sie muss den Import überstehen.
$ziel = $this->sites['ziel']['db'];
$ziel->insert('form_submission', ['id' => 's1', 'form_id' => $ziel->fetchOne('SELECT id FROM form'), 'data' => '{}', 'sender_hash' => 'x', 'created_at' => '2026-10-12 10:00:00', 'delivered' => 0]);
}
protected function tearDown(): void
{
foreach ($this->sites as $site) {
$site['db']->close();
}
gc_collect_cycles();
foreach ($this->sites as $site) {
self::remove($site['root']);
}
}
public function testImportReplacesContentAndKeepsAccountsSubmissionsAndServiceKeys(): void
{
$file = $this->sites['quelle']['package']->export();
$result = $this->sites['ziel']['package']->import($file);
self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
$db = $this->sites['ziel']['db'];
self::assertSame(['neue-seite'], $db->fetchFirstColumn('SELECT path FROM page'));
self::assertSame('Website quelle', $db->fetchOne('SELECT name FROM website'));
// Das Konto der Zielwebsite bleibt - genau der Anlass dieses Imports.
self::assertSame([['username' => 'ziel', 'password_hash' => 'hash-ziel']], $db->fetchAllAssociative('SELECT username, password_hash FROM cms_user'));
// Die Anfrage hängt jetzt am Formular aus dem Paket (gleiche Adresse, neue Kennung).
self::assertSame($db->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT form_id FROM form_submission WHERE id = ?', ['s1']));
self::assertSame($this->sites['quelle']['db']->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT id FROM form'));
// Zugangsdaten zu Diensten bleiben, wo sie waren - und standen auch nicht im Paket.
$settings = json_decode((string) $db->fetchOne('SELECT settings FROM website'), true);
self::assertSame(['geheim-ziel', 'schluessel-ziel'], [$settings['turnstileSecret'], $settings['turnstileSiteKey']]);
$zip = new \ZipArchive();
$zip->open($file);
self::assertStringNotContainsString('geheim-quelle', (string) $zip->getFromName(SitePackage::DATA));
self::assertStringNotContainsString('hash-quelle', (string) $zip->getFromName(SitePackage::DATA), 'Ohne Häkchen keine Konten im Paket.');
$zip->close();
// Der Medienordner ist der des Pakets; der vorige Stand liegt in der Sicherung.
self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
self::assertNotEmpty(glob($this->sites['ziel']['root'].'/var/backups/sicherung-*'), 'Vor dem Import wird gesichert.');
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance');
}
public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void
{
// Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert.
$ohne = $this->sites['quelle']['package']->export();
$result = $this->sites['ziel']['package']->import($ohne, true);
self::assertFalse($result['ok']);
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
// Paket mit Konten, aber ohne Häkchen beim Import: die Konten bleiben.
$mit = $this->sites['quelle']['package']->export(true);
self::assertTrue($this->sites['ziel']['package']->import($mit)['ok']);
self::assertSame(['ziel'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT username FROM cms_user'));
// Mit Häkchen: die Konten des Pakets.
self::assertTrue($this->sites['ziel']['package']->import($mit, true)['ok']);
self::assertSame([['username' => 'quelle', 'password_hash' => 'hash-quelle']], $this->sites['ziel']['db']->fetchAllAssociative('SELECT username, password_hash FROM cms_user'));
}
public function testAPackageFromANewerVersionIsRefused(): void
{
file_put_contents($this->sites['quelle']['root'].'/VERSION', "9.0.0\n");
$result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export());
self::assertFalse($result['ok']);
self::assertStringContainsString('Erst diese Website aktualisieren', $result['steps'][0]['detail']);
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
}
/** @return iterable<string, array{callable(\ZipArchive): void, string}> */
public static function tamperedPackages(): iterable
{
yield 'fremde Datei' => [static fn (\ZipArchive $zip) => $zip->addFromString('../../public/hallo.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
yield 'Skript als Medium' => [static fn (\ZipArchive $zip) => $zip->addFromString('medien/shell.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
yield 'veränderter Inhalt' => [static function (\ZipArchive $zip): void {
$zip->addFromString(SitePackage::DATA, str_replace('Seite der quelle', 'Untergeschoben', (string) $zip->getFromName(SitePackage::DATA)));
}, 'Prüfsumme'];
yield 'fremde Tabelle' => [static function (\ZipArchive $zip): void {
$data = json_decode((string) $zip->getFromName(SitePackage::DATA), true);
$data['tabellen']['form_submission'] = [];
$raw = json_encode($data);
$manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true);
$manifest['sha256'] = hash('sha256', $raw);
$zip->addFromString(SitePackage::DATA, $raw);
$zip->addFromString(SitePackage::MANIFEST, json_encode($manifest));
}, 'gehört nicht in ein Inhaltspaket'];
yield 'ungültiges Dokument' => [static function (\ZipArchive $zip): void {
$data = json_decode((string) $zip->getFromName(SitePackage::DATA), true);
$data['tabellen']['revision'][0]['document'] = json_encode(['schemaVersion' => 1, 'title' => 'x', 'blocks' => [['id' => 'a', 'type' => 'unbekannt']]]);
$raw = json_encode($data);
$manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true);
$manifest['sha256'] = hash('sha256', $raw);
$zip->addFromString(SitePackage::DATA, $raw);
$zip->addFromString(SitePackage::MANIFEST, json_encode($manifest));
}, ''];
}
#[\PHPUnit\Framework\Attributes\DataProvider('tamperedPackages')]
public function testATamperedPackageChangesNothing(callable $tamper, string $message): void
{
$file = $this->sites['quelle']['package']->export();
$zip = new \ZipArchive();
$zip->open($file);
$tamper($zip);
$zip->close();
$result = $this->sites['ziel']['package']->import($file);
self::assertFalse($result['ok']);
self::assertStringContainsString($message, json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page'));
self::assertSame([str_repeat('b', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/public/hallo.php');
}
private static function remove(string $path): void
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ('.' !== $entry && '..' !== $entry) {
is_dir($path.'/'.$entry) ? self::remove($path.'/'.$entry) : @unlink($path.'/'.$entry);
}
}
@rmdir($path);
}
}