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;