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:
1 parent
441e629e80
commit
d464c82ac8
17 files changed
+324
-168
No files matched your search
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user