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);
-49
View File
@@ -14,7 +14,6 @@ use Contao\Image;
use Contao\Input;
use Contao\Message;
use Contao\PageModel;
use Contao\StringUtil;
use Contao\System;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
@@ -213,54 +212,6 @@ final class SurveyDcaListener
return $this->surveyEditorRepository->findMemberChoices();
}
/**
* @return array<int, string>
*/
public function getCategoryOptions(): array
{
$rows = $this->connection->fetchAllAssociative('SELECT id, title FROM tl_survey_category ORDER BY sorting ASC, title ASC');
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['title'];
}
return $options;
}
/**
* @return array<int, string>
*/
public function getUsedCategoryFilterOptions(): array
{
$usedCategoryIds = [];
foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) {
foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) {
$categoryId = (int) $categoryId;
if ($categoryId > 0) {
$usedCategoryIds[$categoryId] = $categoryId;
}
}
}
if ([] === $usedCategoryIds) {
return [];
}
$allCategoryOptions = $this->getCategoryOptions();
$options = [];
foreach (array_values($usedCategoryIds) as $categoryId) {
if (isset($allCategoryOptions[$categoryId])) {
$options[$categoryId] = $allCategoryOptions[$categoryId];
}
}
return $options;
}
/**
* @return array<string, string>
*/
+3 -1
View File
@@ -15,7 +15,9 @@ final class SurveyEditorData
{
$data = new self();
$data->title = (string) $survey->title;
$data->description = (string) $survey->description;
// Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
// entfernen, damit das Textarea reinen Text zeigt.
$data->description = trim(strip_tags((string) $survey->description));
return $data;
}
+1 -38
View File
@@ -199,8 +199,6 @@ final class SurveyRepository
return;
}
$categoryIds = $this->normalizeCategoryIds($survey->category);
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
$assignedMembers = $this->fetchAssignedMembers($surveyId);
$memberIds = [];
$memberNames = [];
@@ -211,8 +209,6 @@ final class SurveyRepository
}
$this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
'assignedMembersFilter' => implode(',', $memberIds),
@@ -333,39 +329,6 @@ final class SurveyRepository
return false !== $qb->executeQuery()->fetchOne();
}
/**
* @param list<int> $categoryIds
*
* @return list<string>
*/
private function fetchCategoryTitles(array $categoryIds): array
{
if ([] === $categoryIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
$categoryIds,
);
$titlesById = [];
foreach ($rows as $row) {
$titlesById[(int) $row['id']] = (string) $row['title'];
}
$titles = [];
foreach ($categoryIds as $categoryId) {
if (isset($titlesById[$categoryId])) {
$titles[] = $titlesById[$categoryId];
}
}
return $titles;
}
/**
* @return list<array{id:int,name:string}>
*/
@@ -410,7 +373,7 @@ final class SurveyRepository
);
return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
}
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
return $model instanceof SurveySubmissionModel ? $model : null;
}
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
return false === $id ? null : $this->findById((int) $id);
}
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
return false === $id ? null : $this->findById((int) $id);
}
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
}
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
{
$now = time();
@@ -264,7 +264,6 @@
{% elseif question.type == 'range' %}
<div class="metric-row">
<div class="metric-box">
<span class="metric-label">Minimum</span>
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
</div>
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Security;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Service\SurveyPermissionService;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SurveyEditorVoter extends Voter
{
public const string EDIT = 'SURVEY_EDIT';
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof SurveyModel;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
return false;
}
return $this->surveyPermissionService->canEditSurvey($subject, $user);
}
}
+23
View File
@@ -112,6 +112,29 @@ final class SurveyFlowService
];
}
/**
* Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label.
*
* @return array{position:int,label:string}|null
*/
public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array
{
if ($targetQuestionId <= 0) {
return null;
}
foreach ($this->getOrderedQuestions($survey) as $index => $question) {
if ((int) $question->id === $targetQuestionId) {
return [
'position' => $index + 1,
'label' => trim((string) $question->question),
];
}
}
return null;
}
/**
* @return list<SurveyContentModel>
*/
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final class SurveyPermissionService
{
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
{
}
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
{
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
}
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
{
if (!$this->canEditSurvey($survey, $member)) {
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
}
}
+4 -8
View File
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
private function buildChartLegend(array $questionResult): array
{
$chart = $questionResult['chart'] ?? null;
if (!is_array($chart)) {
return [];
}
$chartType = (string) ($chart['type'] ?? '');
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
$sourceRows = match ($chartType) {
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
'line' => $questionResult['distribution'] ?? [],
default => [],
// Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen.
default => $questionResult['responseSummary'] ?? [],
};
if (!is_array($sourceRows) || [] === $sourceRows) {
return [];
}
$dataset = $chart['data']['datasets'][0] ?? [];
$dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
$fillColors = match ($chartType) {
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
+15 -10
View File
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
$results = [];
foreach ($questions as $question) {
$key = $this->buildQuestionResultKey(
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
);
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
$results[$key] = $this->buildResultSkeleton(
(int) $question->id,
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
(int) ($question->sorting ?? 0),
);
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
}
foreach ($answers as $answer) {
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
$questionType = (string) ($answer['questionType'] ?? 'text');
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
$questionDescription = (string) ($answer['questionDescription'] ?? '');
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
$key = $this->buildQuestionResultKey($questionId, $questionType);
if (!isset($results[$key])) {
$results[$key] = $this->buildResultSkeleton(
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
return array_values($results);
}
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
private function buildQuestionResultKey(int $questionId, string $questionType): string
{
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
return implode('|', [$questionId, $questionType]);
}
/**
@@ -125,6 +123,7 @@ final class SurveyResultsViewService
'_sorting' => $sorting,
'_values' => [],
'_choiceOptions' => [],
'_allowMaybe' => false,
];
}
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
case 'yes_no_maybe':
$optionCounts = ['Ja' => 0, 'Nein' => 0];
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
if (!empty($result['_allowMaybe'])) {
$optionCounts['Vielleicht'] = 0;
}
foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value));
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
break;
}
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
return $result;
}
+24 -1
View File
@@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid;
final class SurveySubmissionService
{
/**
* Eine abgeschlossene Teilnahme bleibt für diese Dauer auf der Danke-Seite.
* Danach darf dieselbe Browser-Session erneut an der Umfrage teilnehmen.
*/
private const RESTART_COOLDOWN_SECONDS = 60;
public function __construct(
private readonly SurveySubmissionRepository $surveySubmissionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
@@ -35,7 +41,9 @@ final class SurveySubmissionService
if ('' !== $token) {
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
if ($existing instanceof SurveySubmissionModel) {
// Offene Durchläufe werden fortgesetzt; abgeschlossene Durchläufe halten
// die Danke-Seite, bis der Cooldown abgelaufen ist (dann neuer Durchlauf).
if ($existing instanceof SurveySubmissionModel && !$this->isRestartAllowed($existing)) {
return $existing;
}
}
@@ -48,6 +56,21 @@ final class SurveySubmissionService
return $submission;
}
private function isRestartAllowed(SurveySubmissionModel $submission): bool
{
if ('1' !== (string) $submission->isFinished) {
return false;
}
$completedAt = (int) ($submission->completedAt ?? 0);
if ($completedAt <= 0) {
return true;
}
return (time() - $completedAt) >= self::RESTART_COOLDOWN_SECONDS;
}
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);