Files
Jürgen Mummert db7094fd1e 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>
2026-06-14 13:00:05 +02:00

262 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use Contao\DC_Table;
use Contao\System;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
$GLOBALS['TL_DCA']['tl_survey'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'],
'enableVersioning' => true,
'onload_callback' => [
[SurveyDcaListener::class, 'handleDuplicateSurveyAction'],
[SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'],
],
'onsubmit_callback' => [
[SurveyDcaListener::class, 'persistSurveyBackendState'],
],
'sql' => [
'keys' => [
'id' => 'primary',
'alias' => 'unique',
'published' => 'index',
'isActive' => 'index',
'isTemplate' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 1,
'fields' => ['title'],
'flag' => 1,
'panelLayout' => 'filter;sort,search,limit',
],
'label' => [
'fields' => ['title', 'assignedMembersSummary', 'templateSummary', 'internalNote'],
'showColumns' => true,
],
'global_operations' => [
'all' => [
'href' => 'act=select',
'class' => 'header_edit_all',
'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"',
],
],
'operations' => [
'editheader' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
'href' => 'act=edit',
'icon' => 'edit.svg',
'primary' => true,
'showInHeader' => false,
],
'edit' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'],
'href' => 'table=tl_survey_content',
'icon' => 'children.svg',
'primary' => true,
'showInHeader' => false,
],
'toggle' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'],
'href' => 'act=toggle&amp;field=isActive',
'icon' => 'visible.svg',
'primary' => true,
'showInHeader' => false,
],
'publishedWarning' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'],
'icon' => 'important.svg',
'primary' => true,
'button_callback' => [SurveyDcaListener::class, 'generatePublishedWarningButton'],
],
'duplicateSurvey' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'],
'href' => 'key=duplicateSurvey',
'icon' => 'copy.svg',
'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'],
],
'delete' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'],
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'],
'href' => 'act=show',
'icon' => 'show.svg',
],
'copyPublicLink' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'],
'icon' => 'copy.svg',
'button_callback' => [SurveyDcaListener::class, 'generateCopyPublicLinkButton'],
],
'showResults' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['showResults'],
'icon' => 'show.svg',
'button_callback' => [SurveyDcaListener::class, 'generateShowResultsButton'],
],
],
],
'palettes' => [
'default' => '{title_legend},title,category,description,internalNote;{assignment_legend},assignedMembers;{publishing_legend},published,isActive,isTemplate;{meta_legend},createdAt,updatedAt',
],
'fields' => [
'id' => [
'sql' => 'int(10) unsigned NOT NULL auto_increment',
],
'tstamp' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'title' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['title'],
'inputType' => 'text',
'search' => true,
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'alias' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['alias'],
'inputType' => 'text',
'eval' => ['maxlength' => 20, 'readonly' => true, 'alwaysSave' => true, 'tl_class' => 'w50'],
'save_callback' => [[SurveyDcaListener::class, 'generateSurveyAlias']],
'sql' => "varchar(20) NOT NULL default ''",
],
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['description'],
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => 'text NULL',
],
'internalNote' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['internalNote'],
'inputType' => 'text',
'search' => true,
'eval' => ['maxlength' => 255, 'tl_class' => 'clr w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'category' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'],
'inputType' => 'select',
'foreignKey' => 'tl_survey_category.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
'sql' => 'blob NULL',
],
'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''",
],
'isActive' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['isActive'],
'toggle' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12', 'doNotCopy' => true],
'sql' => "char(1) NOT NULL default ''",
],
'isTemplate' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['isTemplate'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''",
],
'templateFilter' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['templateFilter'],
'filter' => true,
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getTemplateFilterOptions'],
'eval' => ['includeBlankOption' => true, 'chosen' => true],
'sql' => "char(1) NOT NULL default ''",
],
'templateSummary' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['templateSummary'],
'sql' => "varchar(16) NOT NULL default ''",
],
'isLocked' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['isLocked'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''",
],
'assignedMembers' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'],
'inputType' => 'checkboxWizard',
'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['multiple' => true, 'class' => 'survey-assigned-members-checkbox-wizard', 'tl_class' => 'clr'],
'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']],
'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']],
'sql' => 'blob NULL',
],
'assignedMembersFilter' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'],
'filter' => true,
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true],
'sql' => 'text NULL',
],
'assignedMembersSummary' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'],
'sql' => 'text NULL',
],
'createdBy' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['createdBy'],
'inputType' => 'text',
'eval' => ['readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'updatedBy' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['updatedBy'],
'inputType' => 'text',
'eval' => ['readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'createdAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['createdAt'],
'inputType' => 'text',
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'updatedAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['updatedAt'],
'inputType' => 'text',
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];
if ('cli' !== PHP_SAPI) {
$hasListMetadataColumns = false;
try {
$columnNames = array_map(
static fn (string $columnName): string => strtolower($columnName),
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
);
$hasListMetadataColumns = !array_diff(
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
} catch (\Throwable) {
$hasListMetadataColumns = false;
}
if (!$hasListMetadataColumns) {
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
unset(
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateFilter'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersSummary'],
);
}
}