Sicherung und Wiederherstellung - und die Wiederherstellung ist nachgewiesen
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Arbeitspaket O. Der Teil, um den es geht, ist nicht das Sichern, sondern das
Zurueckspielen: eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen
ist, ist eine Vermutung.

Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben (VACUUM
INTO). Ein copy() waehrend eines Schreibvorgangs ergibt eine Datei, die
vorhanden aussieht und sich am Tag der Wiederherstellung nicht oeffnen laesst.
Der Medienordner gehoert dazu - ohne die Dateien stellt die Sicherung eine
Website voller kaputter Bilder her. .env gehoert nicht dazu: darin steht
APP_SECRET, und eine Sicherung wird heruntergeladen und weitergereicht. Was
nicht drin ist, steht im Manifest.

Jede Sicherung traegt Zeitpunkt, Anlass, Fassung, Migrationsstand,
Inhaltszahlen und die SHA-256 der Datenbank. Die Uebersicht zeigt
"unversehrt" oder "Pruefsumme falsch" - eine Sicherung, von der man erst beim
Zurueckspielen erfaehrt, dass sie kaputt ist, waere schlimmer als keine.

Beim Zurueckspielen: neuere Fassung wird abgewiesen (mit --force geht es),
zuerst wird der aktuelle Stand gesichert, Wartungszustand an, Medien werden
ergaenzt statt ersetzt - eine Datei, die nach der Sicherung hochgeladen wurde,
bleibt liegen. Die Massnahme gegen Datenverlust darf nicht selbst welchen
verursachen. In der Verwaltung gibt es Liste und Anlegen-Knopf, aber kein
Zurueckspielen: ein Knopf, der die ganze Datenbank ersetzt, gehoert nicht
neben einen, der eine Seite speichert.

Nachgewiesen, zweimal: acht Tests gegen eine wegwerfbare Installation
(Sicherung, Seite und Bilddatei loeschen, zurueckspielen, beides wieder da) -
und von Hand auf der Entwicklungsinstallation mit einer eigens angelegten
Testseite: 5 Seiten, gesichert, geloescht (4), zurueckgespielt (5, Seite mit
ihrer Adresse wieder da), Testseite entfernt (4). Damit ist der aelteste
offene Punkt aus Paket I erledigt.

Ein Fall, den erst der Test zeigte: zwei Sicherungen in derselben Sekunde -
nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie
an. Der Name bekommt jetzt eine Nummer, statt dass die zweite scheitert.

Offen: MySQL (mysqldump fehlt) und ein Download ueber den Browser.

344 PHP-Tests / 1397 Assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 21:37:31 +02:00
1 parent af3589c95c
commit 8c49f6a67a
12 files changed
+767 -5

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Command;
use App\Domain\InvalidPage;
use App\Ops\Backup;
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:backup', description: 'Writes a backup of database and media, and rotates the old ones. Meant for a daily cron job.')]
final class BackupCommand extends Command
{
public function __construct(private Backup $backup)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('keep', null, InputOption::VALUE_REQUIRED, 'Wie viele Sicherungen aufbewahrt werden.', (string) Backup::KEEP)
->addOption('list', null, InputOption::VALUE_NONE, 'Nur auflisten, was vorhanden ist.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('list')) {
$rows = array_map(
static fn (array $backup): array => [
$backup['name'],
$backup['manifest']['erstellt'] ?? '?',
$backup['manifest']['fassung'] ?? '?',
round($backup['bytes'] / 1048576, 1).' MB',
$backup['intact'] ? 'ok' : 'PRÜFSUMME FALSCH',
],
$this->backup->all(),
);
[] === $rows ? $io->writeln('Noch keine Sicherung vorhanden.') : $io->table(['Name', 'Erstellt', 'Fassung', 'Größe', 'Zustand'], $rows);
return Command::SUCCESS;
}
try {
$result = $this->backup->create('Konsole');
} catch (InvalidPage $error) {
$io->error($error->getMessage());
return Command::FAILURE;
}
$removed = $this->backup->rotate((int) $input->getOption('keep'));
$io->success(sprintf(
'%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s',
$result['name'],
$result['manifest']['inhalt']['seiten'],
$result['manifest']['inhalt']['fassungen'],
$result['manifest']['medien']['dateien'],
[] === $removed ? '' : ' Entfernt: '.implode(', ', $removed).'.',
));
$io->writeln('Nicht enthalten: .env, vendor/ und der Quellcode – die kommen aus dem Repository.');
return Command::SUCCESS;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Ops\Backup;
use App\Ops\Updater;
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;
/**
* Restoring is deliberately console only.
*
* A button that replaces the whole database does not belong next to a button that saves a page.
* The administration shows which backups exist and whether they are intact; putting one back is a
* decision made with a shell in hand.
*/
#[AsCommand(name: 'cms:restore', description: 'Puts a backup back in place: checks, safety copy, database, media.')]
final class RestoreCommand extends Command
{
public function __construct(private Backup $backup, private Updater $updater)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('name', InputArgument::REQUIRED, 'Name der Sicherung, etwa sicherung-20260912-181159.')
->addOption('force', null, InputOption::VALUE_NONE, 'Auch eine Sicherung aus einer neueren Fassung einspielen.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = (string) $input->getArgument('name');
if ($input->isInteractive() && !$io->confirm(sprintf('Die Datenbank wird durch %s ersetzt. Fortfahren?', $name), false)) {
$io->writeln('Abgebrochen. Es wurde nichts verändert.');
return Command::SUCCESS;
}
// The site says "come back later" while the database is being replaced.
$this->updater->maintenance(true);
$result = $this->backup->restore($name, (bool) $input->getOption('force'));
foreach ($result['steps'] as $step) {
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? ' ok ' : ' FEHLER', $step['step'], $step['detail']));
}
$this->updater->maintenance(false);
if (!$result['ok']) {
$io->error('Die Wiederherstellung ist nicht gelaufen. Die Installation ist unverändert.');
return Command::FAILURE;
}
$io->success('Wiederhergestellt. Prüfe jetzt die Website und räume danach den Cache: php bin/console cache:clear --env=prod');
return Command::SUCCESS;
}
}
+34 -1
View File
@@ -2,6 +2,8 @@
namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\Backup;
use App\Ops\Updater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater)
public function __construct(private Updater $updater, private Backup $backup)
{
}
@@ -35,6 +37,7 @@ final class SystemController extends AbstractController
'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null,
'asked' => $request->query->getBoolean('pruefen'),
'steps' => $request->getSession()->remove('update_steps') ?: [],
'backups' => $this->backup->all(),
]);
}
@@ -61,6 +64,36 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
/**
* Creating a backup is safe, so it has a button. Putting one back is not, so it has none -
* `cms:restore` is the way, and the list says which backups exist and whether their checksum
* still matches.
*/
#[Route('/admin/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
public function backup(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 {
$result = $this->backup->create('Verwaltung');
} catch (InvalidPage $error) {
return $this->back($error->getMessage());
}
$removed = $this->backup->rotate();
return $this->back(sprintf(
'%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s',
$result['name'],
$result['manifest']['inhalt']['seiten'],
$result['manifest']['inhalt']['fassungen'],
$result['manifest']['medien']['dateien'],
[] === $removed ? '' : ' Ältere entfernt: '.implode(', ', $removed).'.',
), 'success');
}
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
public function maintenance(Request $request): Response
{
+314
View File
@@ -0,0 +1,314 @@
<?php
namespace App\Ops;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Sicherung und Wiederherstellung.
*
* Three things decide whether this is worth anything:
*
* 1. **The database copy has to be consistent.** A plain `copy()` of a SQLite file while something
* writes produces a file that looks fine and opens with an error later. `VACUUM INTO` asks
* SQLite for a complete copy instead.
* 2. **The media folder belongs in it.** Documents reference image IDs; without the files the
* backup restores a site full of broken pictures.
* 3. **`.env` does not.** It holds APP_SECRET, and a backup is something that gets downloaded and
* passed around. It is also not needed to restore content.
*
* Restoring is the part nobody practises, so it is the part with the most checks: a backup from a
* newer version is refused, a safety copy of the current state is taken first, and the media are
* added rather than replaced - deleting files that were uploaded after the backup would turn a
* restore into a second loss.
*/
final class Backup
{
public const MANIFEST = 'sicherung.json';
/** Keep this many, newest first. Without rotation the disk fills and the next backup fails. */
public const KEEP = 10;
public function __construct(
private string $root,
private string $databaseUrl,
private string $mediaDirectory,
private Connection $db,
private CommandRunner $runner,
) {
}
/**
* @return array{name: string, path: string, manifest: array}
*
* @throws InvalidPage
*/
public function create(string $reason = 'manuell', \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
{
// Two backups within the same second must not collide - and the second one must not
// fail either: a restore takes a safety copy, and that can land in the same second as the
// backup it is about to restore.
$name = 'sicherung-'.$now->format('Ymd-His');
$directory = $this->directory().'/'.$name;
for ($suffix = 2; is_dir($directory) && $suffix < 100; ++$suffix) {
$name = sprintf('sicherung-%s-%d', $now->format('Ymd-His'), $suffix);
$directory = $this->directory().'/'.$name;
}
if (is_dir($directory)) {
throw new InvalidPage('Es gibt bereits zu viele Sicherungen mit diesem Zeitstempel.');
}
if (!@mkdir($directory, 0o775, true)) {
throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory));
}
$database = $this->copyDatabase($directory);
$media = $this->copyMedia($directory.'/medien');
$manifest = [
'erstellt' => $now->format('c'),
'anlass' => $reason,
'fassung' => $this->version(),
// A database that has never been migrated by the tool has no such table; that is not
// a reason to refuse a backup of its content.
'migration' => $this->migration(),
'datenbank' => $database,
'medien' => ['dateien' => $media['count'], 'bytes' => $media['bytes']],
'inhalt' => [
'seiten' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE deleted_at IS NULL'),
'fassungen' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
'medien' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'),
],
// Not in here, and it is worth saying so where somebody reads it.
'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'],
];
file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
}
/**
* @return list<array{name: string, path: string, bytes: int, manifest: ?array, intact: bool}>
*/
public function all(): array
{
$backups = [];
foreach (glob($this->directory().'/sicherung-*', GLOB_ONLYDIR) ?: [] as $path) {
$manifest = $this->manifest($path);
$backups[] = [
'name' => basename($path),
'path' => $path,
'bytes' => $this->size($path),
'manifest' => $manifest,
// A backup whose database is missing or whose checksum does not match is not a
// backup, and it must say so before somebody relies on it.
'intact' => null !== $manifest && $this->intact($path, $manifest),
];
}
usort($backups, static fn (array $a, array $b): int => strcmp($b['name'], $a['name']));
return $backups;
}
/** Removes the oldest beyond the limit. Returns the names it removed. */
public function rotate(int $keep = self::KEEP): array
{
$removed = [];
foreach (array_slice($this->all(), max(1, $keep)) as $backup) {
$this->remove($backup['path']);
$removed[] = $backup['name'];
}
return $removed;
}
/**
* Puts a backup back in place.
*
* @return array{steps: list<array{step: string, ok: bool, detail: string}>, ok: bool}
*/
public function restore(string $name, bool $force = false): array
{
$steps = [];
$step = static function (array &$steps, string $name, bool $ok, string $detail): bool {
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
return $ok;
};
$path = $this->directory().'/'.basename($name);
$manifest = $this->manifest($path);
if (!$step($steps, 'Sicherung finden', null !== $manifest, null === $manifest
? sprintf('%s gibt es nicht oder es fehlt das Manifest.', basename($name))
: sprintf('%s vom %s, Fassung %s.', basename($name), $manifest['erstellt'], $manifest['fassung']))) {
return ['steps' => $steps, 'ok' => false];
}
if (!$step($steps, 'Unversehrt', $this->intact($path, $manifest), $this->intact($path, $manifest)
? 'Die Prüfsumme der Datenbank stimmt.'
: 'Die Prüfsumme der Datenbank stimmt nicht. Diese Sicherung wird nicht eingespielt.')) {
return ['steps' => $steps, 'ok' => false];
}
// A backup from a newer version carries a schema this code does not know.
$newer = version_compare((string) $manifest['fassung'], $this->version(), '>');
if (!$step($steps, 'Fassung passt', !$newer || $force, $newer
? sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s. Erst aktualisieren.', $manifest['fassung'], $this->version())
: sprintf('Sicherung %s, installiert %s.', $manifest['fassung'], $this->version()))) {
return ['steps' => $steps, 'ok' => false];
}
try {
$safety = $this->create('vor Wiederherstellung');
$step($steps, 'Aktuellen Stand sichern', true, $safety['name'].' – auch eine Wiederherstellung kann die falsche sein.');
} catch (InvalidPage $error) {
$step($steps, 'Aktuellen Stand sichern', false, $error->getMessage());
return ['steps' => $steps, 'ok' => false];
}
$file = $this->databaseFile();
if (null === $file) {
$step($steps, 'Datenbank zurückschreiben', false, 'Diese Installation nutzt keine Dateidatenbank. Für MySQL fehlt das Einspielen noch (siehe docs/UPDATE.md).');
return ['steps' => $steps, 'ok' => false];
}
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
}
// Media are added, never deleted: files uploaded after the backup would otherwise be lost
// by the very operation that is meant to prevent loss.
$restored = $this->restoreMedia($path.'/medien');
$step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored));
return ['steps' => $steps, 'ok' => true];
}
private function migration(): string
{
try {
return (string) $this->db->fetchOne('SELECT MAX(version) FROM migration_versions');
} catch (\Throwable) {
return 'unbekannt';
}
}
public function directory(): string
{
return $this->root.'/var/backups';
}
public function version(): string
{
$file = $this->root.'/VERSION';
return is_readable($file) ? trim((string) file_get_contents($file)) : '0.0.0';
}
/** @return array{datei: string, bytes: int, sha256: string} */
private function copyDatabase(string $directory): array
{
$file = $this->databaseFile();
if (null === $file) {
if (!$this->runner->has('mysqldump', $this->root)) {
throw new InvalidPage('Diese Installation nutzt keine Dateidatenbank, und mysqldump ist nicht aufrufbar. Ohne eines von beiden gibt es keine Sicherung.');
}
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
}
$target = $directory.'/'.basename($file);
// VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and
// it would not say so until the day it is needed. The path is ours, not user input.
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
if (!is_readable($target)) {
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
}
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
}
/** @return array{count: int, bytes: int} */
private function copyMedia(string $target): array
{
if (!is_dir($this->mediaDirectory)) {
return ['count' => 0, 'bytes' => 0];
}
@mkdir($target, 0o775, true);
$count = 0;
$bytes = 0;
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $target.'/'.basename($file))) {
++$count;
$bytes += (int) filesize($file);
}
}
return ['count' => $count, 'bytes' => $bytes];
}
private function restoreMedia(string $source): int
{
if (!is_dir($source)) {
return 0;
}
@mkdir($this->mediaDirectory, 0o775, true);
$count = 0;
foreach (glob($source.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $this->mediaDirectory.'/'.basename($file))) {
++$count;
}
}
return $count;
}
private function manifest(string $path): ?array
{
$file = $path.'/'.self::MANIFEST;
if (!is_readable($file)) {
return null;
}
try {
$manifest = json_decode((string) file_get_contents($file), true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($manifest) ? $manifest : null;
}
private function intact(string $path, array $manifest): bool
{
$file = $path.'/'.($manifest['datenbank']['datei'] ?? '');
if (!is_readable($file)) {
return false;
}
return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file));
}
private function size(string $path): int
{
$bytes = 0;
foreach (glob($path.'/*') ?: [] as $entry) {
$bytes += is_dir($entry) ? $this->size($entry) : (int) filesize($entry);
}
return $bytes;
}
private function remove(string $path): void
{
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $this->remove($entry) : @unlink($entry);
}
@rmdir($path);
}
private function databaseFile(): ?string
{
if (!str_starts_with($this->databaseUrl, 'sqlite:')) {
return null;
}
$path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl);
return '' === $path ? null : str_replace('\\', '/', (string) $path);
}
}