Fix lost form submissions on archives, the unlocked update finish and six more

Update Symfony to 7.4.23 (12 advisories in 8 packages).

- A form can be sent from /beitraege, /kategorie/... and /schlagwort/...;
  it answered 404 before anything was stored or mailed.
- Booleans reach the database as integers, so MySQL in strict mode
  accepts form submissions, task switches and plugin states.
- A stepped form with a required multiple choice shows its message
  instead of stopping on a TypeError.
- The second update step runs under the update lock; a concurrent
  finish no longer rolls a successful update back.
- Accordion entries and gallery images can be selected from the
  editor panel again.
- Category and tag names are escaped in the archive heading.
- Restoring a published page from the bin needs the publisher role.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.2 committed 2026-11-10 11:15:49 +01:00
1 parent 441e629e80
commit d464c82ac8
17 files changed
+324 -168

No files matched your search

+5 -2
View File
@@ -707,7 +707,10 @@ final class FormRepository
'id' => $id, 'form_id' => $formId,
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'sender_hash' => $senderHash, 'created_at' => $now,
'consent_at' => $consent ? $now : null, 'delivered' => false,
'consent_at' => $consent ? $now : null,
// Als Zahl: ohne Typangabe wird false als leere Zeichenkette gebunden, und MySQL im
// Strict-Modus lehnt die für eine Zahlenspalte ab. SQLite nimmt beides.
'delivered' => 0,
]);
return $id;
@@ -715,7 +718,7 @@ final class FormRepository
public function markDelivered(string $submissionId): void
{
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [1, $submissionId]);
}
/**
+6
View File
@@ -190,6 +190,12 @@ final class PageRepository
);
}
/** Ob eine Seite eine veröffentlichte Fassung trägt - auch wenn sie im Papierkorb liegt. */
public function hasPublication(string $pageId): bool
{
return false !== $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND website_id = ? AND published_revision_id IS NOT NULL', [$pageId, 'default']);
}
public function trashPage(string $pageId, int $expectedVersion): int
{
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
+8 -1
View File
@@ -332,13 +332,20 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
// Der Papierkorb lässt die veröffentlichte Fassung stehen: eine Seite, die online war, ist
// nach dem Wiederherstellen sofort wieder online. Das entscheidet, wer veröffentlichen
// darf (ADR 116) - wie beim Weg in den Papierkorb.
$live = $this->pages->hasPublication($id);
if ($live && !$this->isGranted('ROLE_PUBLISHER')) {
return $this->back('Diese Seite war veröffentlicht und wäre nach dem Wiederherstellen sofort wieder öffentlich. Das macht, wer veröffentlichen darf.');
}
try {
$this->pages->restorePage($id);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
return $this->back($live ? 'Die Seite ist zurück und wieder öffentlich.' : 'Die Seite ist zurück. Sie ist ein Entwurf, bis sie veröffentlicht wird.', 'success');
}
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
+7 -2
View File
@@ -43,8 +43,12 @@ final class FormController extends AbstractController
{
$form = $this->forms->findBySlug($slug);
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
// Ein Archiv (Beiträge, Kategorie, Schlagwort) ist keine gespeicherte Seite, trägt aber Kopf
// und Fuß wie jede andere - und damit oft das Kontaktformular. Ohne diese Ausnahme endete
// das Absenden dort in einer 404, und die Anfrage war weg.
$archive = 1 === preg_match(PageController::ARCHIVE_PATH, $path);
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
$page = $this->pages->findPublished($path) ?? $this->plugins->page('/'.$path);
$page = $archive ? ['id' => null, 'path' => $path] : ($this->pages->findPublished($path) ?? $this->plugins->page('/'.$path));
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
@@ -65,7 +69,8 @@ final class FormController extends AbstractController
$language = $request->request->getString('language');
$translatedPath = null;
if ($this->languages->isExtra($language)) {
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
// Archive heißen in jeder Sprache gleich und stehen nur hinter der Sprache.
$translatedPath = $archive ? $path : ($this->pages->translatedPaths($page['id'])[$language] ?? null);
}
$language = null === $translatedPath ? null : $language;
+12 -2
View File
@@ -49,6 +49,9 @@ final class PageController extends AbstractController
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
/** Die Adressen der Archive, ohne Sprache davor: Beiträge, eine Kategorie, ein Schlagwort. */
public const ARCHIVE_PATH = '#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response
@@ -60,6 +63,11 @@ final class PageController extends AbstractController
if ('' !== $path && $this->languages->isExtra($segments[0])) {
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
}
// Nur der Formularversand kommt mit einer Archivadresse hier an (die Route selbst schließt
// sie aus): er zeigt das Archiv mit den Meldungen des Formulars noch einmal.
if (1 === preg_match(self::ARCHIVE_PATH, $path, $archiv)) {
return $this->renderArchive($request, str_starts_with($path, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
}
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
return $this->notFound($request);
@@ -111,7 +119,7 @@ final class PageController extends AbstractController
return $this->shell($this->redirect('/'.$locale, 301));
}
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
if (1 === preg_match(self::ARCHIVE_PATH, $rest, $archiv)) {
$this->translation = ['locale' => $locale, 'map' => $map];
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
@@ -933,7 +941,9 @@ final class PageController extends AbstractController
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
// eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
// Ab Schemaversion 12 wird der Text einer Überschrift als Markup gedruckt. Der Name
// einer Kategorie ist aber freier Text, den niemand gefiltert hat - also maskieren.
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => htmlspecialchars($heading, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
];
if (null !== $term) {
+10
View File
@@ -177,6 +177,9 @@ final class SystemController extends AbstractController
@set_time_limit(0);
if ($request->request->getBoolean('finish')) {
$ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
@@ -271,6 +274,10 @@ final class SystemController extends AbstractController
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
if ($ergebnis['laeuft'] ?? false) {
// Der andere Abschluss schreibt den Stand fort; die Systemseite fragt ihn weiter ab.
return new JsonResponse(['ok' => true, 'meldung' => ArchiveUpdate::LAEUFT_BEREITS, 'status' => $this->archive->status()]);
}
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
}
@@ -311,6 +318,9 @@ final class SystemController extends AbstractController
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
@@ -62,7 +62,7 @@ final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
}
@ignore_user_abort(true);
@set_time_limit(0);
$this->archive->finish($this->installation, $this->pruefung);
$this->archive->finish($this->installation, $this->pruefung, true);
} catch (\Throwable) {
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
} finally {
+24 -1
View File
@@ -23,6 +23,8 @@ namespace App\Ops;
*/
final class ArchiveUpdate
{
public const LAEUFT_BEREITS = 'Der Abschluss läuft bereits in einer anderen Anfrage. Diese Seite in einem Moment neu laden.';
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
@@ -363,7 +365,28 @@ final class ArchiveUpdate
* gibt null zurück oder den Grund, warum sie nicht
* läuft - dann wird zurückgeschaltet
*/
public function finish(Installation $installation, ?callable $pruefen = null): array
public function finish(Installation $installation, ?callable $pruefen = null, bool $gesperrt = false): array
{
// Nur ein Abschluss zur selben Zeit. Die Zwischenseite ruft ihn von selbst auf und bietet
// daneben einen Link an: Zwei Abschlüsse nebeneinander ließen den zweiten an der bereits
// laufenden Migration scheitern - und der schaltete dann zurück, Code und Datenbank,
// obwohl der erste gelungen war. $gesperrt: der Aufrufer hält die Sperre schon.
$sperre = $gesperrt ? null : $this->updater->lock();
if (!$gesperrt && null === $sperre) {
return ['ok' => false, 'laeuft' => true, 'steps' => [['step' => 'Abschluss', 'ok' => true, 'detail' => self::LAEUFT_BEREITS]]];
}
try {
return $this->abschliessen($installation, $pruefen);
} finally {
if (null !== $sperre) {
flock($sperre, LOCK_UN);
fclose($sperre);
}
}
}
/** @param (callable(): ?string)|null $pruefen */
private function abschliessen(Installation $installation, ?callable $pruefen): array
{
$schritte = [];
$offen = $this->pending();
+2 -2
View File
@@ -291,7 +291,7 @@ final class Scheduler
$this->db->insert('scheduled_task', [
'name' => $name,
'interval_seconds' => $task->interval(),
'enabled' => true,
'enabled' => 1,
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
'last_message' => '',
'last_duration_ms' => 0,
@@ -357,7 +357,7 @@ final class Scheduler
public function enable(string $name, bool $on): void
{
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]);
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [(int) $on, $name]);
$this->refreshState();
}
+3 -1
View File
@@ -160,8 +160,10 @@ final class PluginRegistry
private function write(string $name, array $fields): void
{
$existing = $this->rows()[$name] ?? null;
// Wahrheitswerte als Zahl: false ginge sonst als leere Zeichenkette an die Datenbank.
$fields = array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $fields);
if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
$this->db->insert('plugin', ['name' => $name, 'active' => 0, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
} else {
$this->db->update('plugin', $fields, ['name' => $name]);
}