Redirect with a flash message instead of a 500 page on stale survey actions

Actions from outdated tabs (survey deleted meanwhile, assignment revoked,
session expired) hit guard clauses that threw RuntimeException and produced
an HTTP 500 error page - seen 11x during the customer pretest on 2026-08-27
(duplicate on a deleted survey, edit link to a deleted survey). All frontend
guards now add a clear flash message and redirect to a freshly loaded view:

- list actions: distinct messages for deleted survey, revoked assignment,
  expired session, unknown action
- editor: resolveSurvey redirects to the survey overview; editor POST with
  invalid token redirects back to the editor
- reader/preview back button: reload the current question instead of failing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:57:00 +02:00
co-authored by Claude Fable 5
parent 024c97fce5
commit 965ce47d48
3 changed files with 57 additions and 25 deletions
@@ -75,7 +75,12 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $template->getResponse(); return $template->getResponse();
} }
$survey = $this->resolveSurvey($request, $memberId); $survey = $this->resolveSurvey($request, $memberId, $model);
if ($survey instanceof Response) {
return $survey;
}
$createQuestionMode = $this->isCreateQuestionRequested($request); $createQuestionMode = $this->isCreateQuestionRequested($request);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) { if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
@@ -225,7 +230,12 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $template->getResponse(); return $template->getResponse();
} }
private function resolveSurvey(Request $request, int $memberId): ?SurveyModel /**
* Liefert die angeforderte Umfrage, null (kein survey-Parameter, z. B. Anlegen-Modus)
* oder einen Redirect zur Übersicht: Aufrufe aus veralteten Tabs oder Lesezeichen
* (Umfrage gelöscht, Zuweisung entzogen) dürfen keine 500er-Fehlerseite erzeugen.
*/
private function resolveSurvey(Request $request, int $memberId, ModuleModel $model): SurveyModel|Response|null
{ {
$surveyId = (int) $request->query->get('survey', 0); $surveyId = (int) $request->query->get('survey', 0);
@@ -236,11 +246,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$survey = $this->surveyRepository->findById($surveyId); $survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) { if (!$survey instanceof SurveyModel) {
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.'); $this->addFlash('error', 'Die Umfrage wurde nicht gefunden sie wurde möglicherweise inzwischen gelöscht.');
return new RedirectResponse($this->resolveListUrl($model));
} }
if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) { if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) {
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.'); $this->addFlash('error', 'Sie sind dieser Umfrage nicht (mehr) zugewiesen.');
return new RedirectResponse($this->resolveListUrl($model));
} }
return $survey; return $survey;
@@ -301,7 +315,9 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$token = (string) $request->request->get('_token'); $token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) { if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.'); $this->addFlash('error', 'Ihre Sitzung war zwischenzeitlich abgelaufen. Bitte versuchen Sie es erneut.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} }
try { try {
@@ -100,18 +100,39 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
$action = (string) $request->request->get('_survey_action'); $action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token', ''); $token = (string) $request->request->get('_token', '');
// Aktionen aus veralteten Tabs (Umfrage inzwischen gelöscht, Sitzung
// abgelaufen) dürfen keine 500er-Fehlerseite erzeugen: stattdessen
// Hinweis als Flash-Meldung und zurück zur frisch geladenen Übersicht.
$redirect = new RedirectResponse($request->getBaseUrl().$request->getPathInfo());
if ($surveyId <= 0 || '' === $action) { if ($surveyId <= 0 || '' === $action) {
throw new \RuntimeException('Ungültige Umfrage-Aktion.'); $this->addFlash('error', 'Die Aktion konnte nicht zugeordnet werden. Bitte versuchen Sie es erneut.');
return $redirect;
} }
// Session-basierter Symfony-Token (wie im Editor): zuverlässig für // Session-basierter Symfony-Token (wie im Editor): zuverlässig für
// eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden // eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden
// und im Frontend-Fragment-Flow nicht stabil verfügbar. // und im Frontend-Fragment-Flow nicht stabil verfügbar.
if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) { if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.'); $this->addFlash('error', 'Ihre Sitzung war zwischenzeitlich abgelaufen. Bitte versuchen Sie es erneut.');
return $redirect;
} }
$survey = $this->resolveEditableSurvey($surveyId, $memberId); $survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) {
$this->addFlash('error', 'Diese Umfrage existiert nicht mehr sie wurde möglicherweise inzwischen gelöscht. Die Übersicht wurde aktualisiert.');
return $redirect;
}
if (!$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
$this->addFlash('error', 'Sie sind dieser Umfrage nicht mehr zugewiesen. Die Übersicht wurde aktualisiert.');
return $redirect;
}
try { try {
if ('duplicate-survey' === $action) { if ('duplicate-survey' === $action) {
@@ -140,17 +161,6 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
return new RedirectResponse($request->getBaseUrl().$request->getPathInfo()); return new RedirectResponse($request->getBaseUrl().$request->getPathInfo());
} }
private function resolveEditableSurvey(int $surveyId, int $memberId): SurveyModel
{
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
return $survey;
}
private function resolvePage(int $pageId): ?PageModel private function resolvePage(int $pageId): ?PageModel
{ {
if ($pageId <= 0) { if ($pageId <= 0) {
@@ -65,7 +65,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null); $submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null);
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) { if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-back-'.(int) $survey->id); // Ungültiger Token (Sitzung abgelaufen, veralteter Tab): kein 500er,
// sondern die aktuelle Frage per Redirect neu laden.
if (!$this->isNavigationTokenValid($request, 'survey-back-'.(int) $survey->id)) {
return new RedirectResponse($request->getUri());
}
if ($this->surveySubmissionService->goBack($survey, $submission, $request->getSession())) { if ($this->surveySubmissionService->goBack($survey, $submission, $request->getSession())) {
return new RedirectResponse($request->getUri()); return new RedirectResponse($request->getUri());
@@ -170,7 +174,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$question = $this->resolvePreviewQuestion($survey, (int) $request->request->get('preview_question', 0)); $question = $this->resolvePreviewQuestion($survey, (int) $request->request->get('preview_question', 0));
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) { if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id); // Ungültiger Token: Entwurfsansicht ohne Fehlerseite von vorn beginnen
// (der Zustand wandert nur durch POST-Felder, ein GET startet neu).
if (!$this->isNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id)) {
return new RedirectResponse($request->getUri());
}
if ([] !== $history) { if ([] !== $history) {
$question = $this->resolvePreviewQuestion($survey, (int) array_pop($history)); $question = $this->resolvePreviewQuestion($survey, (int) array_pop($history));
@@ -285,13 +293,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
return implode(',', $history); return implode(',', $history);
} }
private function assertNavigationTokenValid(Request $request, string $tokenId): void private function isNavigationTokenValid(Request $request, string $tokenId): bool
{ {
$token = (string) $request->request->get('_navigation_token', ''); $token = (string) $request->request->get('_navigation_token', '');
if (!$this->isCsrfTokenValid($tokenId, $token)) { return $this->isCsrfTokenValid($tokenId, $token);
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
} }
private function resolvePublicAlias(Request $request): string private function resolvePublicAlias(Request $request): string