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

+33 -2
View File
@@ -9,6 +9,9 @@ final class DocumentValidator
{
private bool $writing = false;
/** @var array<string, true> beim Lesen bereits geprüfte Dokumente dieser Anfrage */
private array $passed = [];
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
{
}
@@ -59,6 +62,24 @@ final class DocumentValidator
* lässt unpassende Eigenschaften ohnehin nicht durch.
*/
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
{
// Beim Lesen wird dasselbe Dokument in einer Anfrage oft mehrfach geholt (Seite,
// Brotkrumen, strukturierte Daten). Geprüft wird es einmal; der Schlüssel ist sein Inhalt,
// ein geändertes Dokument ist also ein anderes.
$seen = null;
if (!$writing) {
$seen = hash('xxh128', serialize($document)).($requireHeading ? '1' : '0');
if (isset($this->passed[$seen])) {
return;
}
}
$this->check($document, $requireHeading, $writing);
if (null !== $seen) {
$this->passed[$seen] = true;
}
}
private function check(array $document, bool $requireHeading, bool $writing): void
{
$this->writing = $writing;
$schemaVersion = $document['schemaVersion'] ?? null;
@@ -133,7 +154,7 @@ final class DocumentValidator
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
}
try {
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
$this->check(Translations::apply($document, $locale), $requireHeading, $this->writing);
} catch (InvalidDocument $error) {
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
}
@@ -171,7 +192,17 @@ final class DocumentValidator
// and is answered in the controller, not here.
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own));
'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]);
// Eigenes CSS wird nur beim Schreiben geparst. Was gespeichert ist, galt beim Speichern;
// ist eine Regel inzwischen strenger, lässt compileCss() das CSS dieses einen Blocks
// weg (ADR 116). Hier beim Lesen zu werfen hieße: die Seite antwortet mit 500 und lässt
// sich nicht einmal mehr zum Reparieren öffnen.
if ('css' === $own) {
if ($this->writing) {
$this->code->checkCss($block[$own]);
}
} else {
$this->code->checkScript($block[$own]);
}
}
if (isset($block['style'])) {
// Free styling: any property of StyleSchema, per device, with an optional hover value.
+23 -5
View File
@@ -219,7 +219,17 @@ final class SiteSettings
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
}
public function save(array $input): void
/**
* Kennung des gespeicherten Stands. Das Formular trägt sie mit; hat inzwischen jemand anderes
* gespeichert, passt sie nicht mehr, und save() lehnt ab, statt dessen Änderung zu überschreiben.
*/
public function stamp(): string
{
return hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
}
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
public function save(array $input, ?string $expected = null): void
{
$values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page.
@@ -412,10 +422,18 @@ final class SiteSettings
}
$this->loaded = null;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
$this->db->transactional(function () use ($values, $expected): void {
if (null !== $expected) {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
throw new StaleWrite('Die Einstellungen wurden inzwischen an anderer Stelle geändert. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
}
}
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
});
}
/** Change only the 404 assignment, preserving settings saved in other forms. */
+20 -5
View File
@@ -108,7 +108,14 @@ final class Theme
];
}
public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void
/** Kennung des gespeicherten Stands, wie SiteSettings::stamp(). */
public function stamp(): string
{
return hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
}
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
public function save(array $colors, array $fonts, array $base, ?array $buttons = null, ?string $expected = null): void
{
$theme = [
'colors' => $this->colors($colors),
@@ -132,10 +139,18 @@ final class Theme
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
$this->db->transactional(function () use ($theme, $expected): void {
if (null !== $expected) {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
throw new \App\Domain\StaleWrite('Das Design wurde inzwischen an anderer Stelle geändert – etwa durch eine im Editor gespeicherte Stilvorlage. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
});
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
+4 -2
View File
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [
'settings' => $settings,
'settingsStamp' => $this->settings->stamp(),
'businessTypes' => SiteSettings::BUSINESS_TYPES,
'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(),
@@ -124,8 +125,9 @@ final class SettingsController extends AbstractController
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->settings->save($request->request->all());
} catch (InvalidPage $e) {
// Ohne Kennung (ein Formular von vor dem Update) wird nicht geprüft, sondern gespeichert.
$this->settings->save($request->request->all(), $request->request->getString('expected') ?: null);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
+3 -2
View File
@@ -27,6 +27,7 @@ final class ThemeController extends AbstractController
return $this->render('admin/theme.html.twig', [
'theme' => $theme,
'themeStamp' => $this->theme->stamp(),
'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS,
'buttonOptions' => ButtonDesign::OPTIONS,
@@ -93,8 +94,8 @@ final class ThemeController extends AbstractController
$fonts[] = $row;
}
try {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null);
} catch (InvalidPage $e) {
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null, $request->request->getString('expected') ?: null);
} catch (InvalidPage|\App\Domain\StaleWrite $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_theme');
+15 -10
View File
@@ -195,16 +195,7 @@ final class ArchiveUpdate
return ['ok' => false, 'steps' => $schritte];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
@@ -250,6 +241,20 @@ final class ArchiveUpdate
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
// Erst sichern, dann anfassen - aber erst jetzt, hinter der Wartungsseite. Vorher entstand
// die Sicherung vor dem Laden und Entpacken: Was in diesen Sekunden bis Minuten noch
// hereinkam (eine Anfrage über ein Formular, ein gespeicherter Entwurf), fehlte in ihr und
// war nach einem Zurückschalten weg.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
} catch (\Throwable $fehler) {
$this->updater->maintenance(false);
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
+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
+53 -18
View File
@@ -207,19 +207,34 @@ final class SitePackage
return $fail();
}
// Eine Wartung, die schon vorher an war, hat jemand mit Absicht eingeschaltet - die
// hebt ein Import nicht auf.
$inWartung = is_file($this->root.'/'.Updater::MAINTENANCE_FILE);
$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.');
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
return $fail();
return $fail();
}
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
// Ab hier ist die Datenbank die des Pakets. Scheitern die Medien, sagt die Meldung
// genau das - und nennt die Sicherung, mit der sich beides zurücknehmen lässt.
try {
$moved = $this->replaceMedia($media);
} catch (\Throwable $e) {
$step('Medien ersetzen', false, sprintf('%s Die Inhalte sind bereits die des Pakets, die Medien nicht vollständig. Zurück auf den Stand vorher: cms:restore %s.', $e->getMessage(), $safety['name']));
return $fail();
}
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
} finally {
$this->updater->maintenance(false);
if (!$inWartung) {
$this->updater->maintenance(false);
}
if (isset($media)) {
$this->remove($media);
}
@@ -435,25 +450,45 @@ final class SitePackage
return $target;
}
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
/**
* Legt die Dateien des Pakets in den Medienordner und entfernt danach, was nicht dazugehört.
*
* In dieser Reihenfolge: Erst wenn jede Datei des Pakets angekommen ist, verschwinden die
* alten. Vorher wurde zuerst geleert und dann kopiert, ohne nachzuzählen - bei knappem
* Speicher meldete der Import Erfolg, und die Website verwies auf Bilder, die es nicht gab.
*
* @throws \RuntimeException wenn eine Datei nicht ankommt; der Medienordner hat dann noch alle alten Dateien
*/
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.');
}
$neu = [];
foreach (scandir($source) ?: [] as $name) {
if (1 !== preg_match(self::MEDIA_NAME, $name)) {
continue;
}
$size = (int) filesize($source.'/'.$name);
$target = $this->mediaDirectory.'/'.$name;
// Verschieben braucht keinen zweiten Platz auf der Platte; über Laufwerksgrenzen
// hinweg bleibt das Kopieren.
if (!@rename($source.'/'.$name, $target) && !@copy($source.'/'.$name, $target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s ließ sich nicht ablegen. Freien Speicherplatz prüfen.', $name));
}
clearstatcache(true, $target);
if ($size !== (int) @filesize($target)) {
throw new \RuntimeException(sprintf('Die Mediendatei %s kam unvollständig an. Freien Speicherplatz prüfen.', $name));
}
$neu[$name] = true;
}
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name)) {
if (!isset($neu[$name]) && 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;
return \count($neu);
}
private function summary(array $tables, bool $withUsers): string