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:
1 parent
0d61aa0f22
commit
ead47cc924
25 files changed
+437
-72
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user