Fix frontend CSRF, clean reader URLs, and review findings

CSRF / actions:
- List action forms now send both REQUEST_TOKEN (Contao gate) and _token
  (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes
  the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete

Public reader / URLs:
- Reader & results read auto_item via Input::get (marks the route param used)
  so clean path URLs /<page>/<alias> no longer 404
- List controller generates path URLs via ['parameters' => '/'.$alias]
- Allow re-participation in the same session after a 60s cooldown

Review fixes:
- Results: merge answer snapshots by questionId+type (no more split cards on
  label edits); seed "Vielleicht" when allowMaybe so screen matches Excel
- Reader: replace generic answer hint with per-option "weiter zu Frage X"
  only when a jump is configured; strip HTML tags from survey description
- PDF: render free-text frequency summary; drop duplicate "Minimum" label
- Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS
  from _survey_assets, throttle the backend MutationObserver
- Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en)
- Remove dead code: SurveyEditorVoter, SurveyPermissionService,
  categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key

Reader layout:
- max-width 1000px (other views keep 1400px); back/next buttons fill the
  width as equal, card-sized halves; sort handle icon offset tweak

Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jürgen Mummert
2026-06-14 13:00:05 +02:00
parent 1b16675ae9
commit db7094fd1e
29 changed files with 230 additions and 456 deletions
@@ -68,9 +68,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
];
}
@@ -90,8 +90,16 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
{
$surveyId = (int) $request->request->get('item_id', 0);
$action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token', '');
if ($surveyId <= 0 || '' === $action) {
throw new \RuntimeException('Ungültige Umfrage-Aktion.');
}
// Session-basierter Symfony-Token (wie im Editor): zuverlässig für
// eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden
// und im Frontend-Fragment-Flow nicht stabil verfügbar.
if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
@@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
return $template->getResponse();
}
/**
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
*
* @return array<string, array{position:int,label:string}>
*/
private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array
{
if ('yes_no_maybe' !== (string) $question->type) {
return [];
}
$hints = [];
if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) {
$hints['yes'] = $yes;
}
if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) {
$hints['no'] = $no;
}
return $hints;
}
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
{
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
// Bei sauberen Pfad-URLs (/seite/<alias>) legt Contao den Wert im Input-Adapter
// ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert
// ihn als benutzt sonst wirft Contao 404 (unbenutzter Route-Parameter).
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
@@ -8,6 +8,7 @@ use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
// Bei sauberen Pfad-URLs (/seite/<alias>) liegt der Wert im Contao-Input-Adapter;
// Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404).
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);