Keep old pages readable, detect stale settings writes, make backups honest

- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.2 committed 2026-11-10 11:41:05 +01:00
1 parent 0d61aa0f22
commit ead47cc924
25 files changed
+437 -72

No files matched your search

+72 -10
View File
@@ -69,6 +69,21 @@ final class Backup
throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory));
}
// Scheitert etwas - typisch: der Speicher ist voll -, bleibt kein halber Ordner liegen. Der
// zählte sonst bei der Rotation als eine der neuesten Sicherungen und verdrängte eine
// vollständige.
try {
return $this->write($name, $directory, $reason, $now);
} catch (\Throwable $fehler) {
$this->remove($directory);
throw $fehler;
}
}
/** @return array{name: string, path: string, manifest: array} */
private function write(string $name, string $directory, string $reason, \DateTimeImmutable $now): array
{
$database = $this->copyDatabase($directory);
$media = $this->copyMedia($directory.'/medien');
$manifest = [
@@ -88,7 +103,10 @@ final class Backup
// 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));
$text = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (strlen($text) !== @file_put_contents($directory.'/'.self::MANIFEST, $text)) {
throw new InvalidPage('Das Verzeichnis der Sicherung ließ sich nicht vollständig schreiben. Freien Speicherplatz prüfen.');
}
return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
}
@@ -116,13 +134,23 @@ final class Backup
return $backups;
}
/** Removes the oldest beyond the limit. Returns the names it removed. */
/**
* Removes the oldest beyond the limit. Returns the names it removed.
*
* Gezählt werden nur unversehrte Sicherungen: eine beschädigte darf keine vollständige
* verdrängen. Beschädigte verschwinden selbst - aber erst nach einer Stunde, denn ein Ordner
* ohne Verzeichnis kann auch eine Sicherung sein, die eine andere Anfrage gerade schreibt.
*/
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'];
$intact = 0;
foreach ($this->all() as $backup) {
$drop = $backup['intact'] ? ++$intact > max(1, $keep) : (int) @filemtime($backup['path']) < time() - 3600;
if ($drop) {
$this->remove($backup['path']);
$removed[] = $backup['name'];
}
}
return $removed;
@@ -184,7 +212,21 @@ final class Backup
}
} else {
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
// Erst vollständig daneben ablegen, dann in einem Zug an die Stelle setzen: eine halb
// geschriebene Datenbank sieht so nie jemand. Journal und WAL der alten Datei müssen
// weg - SQLite spielte sie sonst beim nächsten Öffnen in die zurückgeholte Datei ein.
$quelle = $path.'/'.basename($file);
$neben = $file.'.wiederherstellung';
$copied = @copy($quelle, $neben) && filesize($quelle) === filesize($neben);
if ($copied) {
foreach (['-journal', '-wal', '-shm'] as $rest) {
@unlink($file.$rest);
}
// Unter Windows scheitert das Umbenennen, solange ein anderer Prozess die Datei
// offen hat; dann bleibt der bisherige Weg.
$copied = @rename($neben, $file) || @copy($neben, $file);
}
@unlink($neben);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
}
@@ -194,6 +236,12 @@ final class Backup
// 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));
// Eine Sicherung aus einer älteren Fassung bringt deren Datenbankschema mit. Der Code hier
// ist neuer und erwartet Spalten, die es dann noch nicht gibt - das muss dastehen, sonst
// sucht man die 500er an der falschen Stelle.
if (version_compare((string) $manifest['fassung'], $this->version(), '<')) {
$step($steps, 'Migrationen', true, sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s: Jetzt in der Verwaltung unter System „Migrationen ausführen“ – oder: php bin/console doctrine:migrations:migrate.', $manifest['fassung'], $this->version()));
}
return ['steps' => $steps, 'ok' => true];
}
@@ -303,10 +351,17 @@ final class Backup
$count = 0;
$bytes = 0;
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $target.'/'.basename($file))) {
++$count;
$bytes += (int) filesize($file);
if (!is_file($file)) {
continue;
}
$size = (int) filesize($file);
// Eine Kopie, die fehlt oder kürzer ist, ist keine: lieber gar keine Sicherung als
// eine, die vollständig aussieht und beim Zurückspielen Bilder vermissen lässt.
if (!@copy($file, $target.'/'.basename($file)) || $size !== (int) @filesize($target.'/'.basename($file))) {
throw new InvalidPage(sprintf('Die Mediendatei %s ließ sich nicht sichern. Freien Speicherplatz und Schreibrechte auf var/backups prüfen.', basename($file)));
}
++$count;
$bytes += $size;
}
return ['count' => $count, 'bytes' => $bytes];
@@ -350,7 +405,14 @@ final class Backup
return false;
}
return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file));
if (!hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file))) {
return false;
}
// Auch die Medien gehören dazu: fehlt eine der gezählten Dateien, ist die Sicherung nicht
// mehr die, die das Verzeichnis beschreibt.
$erwartet = $manifest['medien']['dateien'] ?? null;
return !is_int($erwartet) || $erwartet <= count(array_filter(glob($path.'/medien/*') ?: [], 'is_file'));
}
private function size(string $path): int