Sanitize survey frontend and PDF output

This commit is contained in:
Jürgen Mummert
2026-05-24 17:53:53 +02:00
parent 64fc578d4c
commit 6eec9ec537
6 changed files with 84 additions and 41 deletions
@@ -1,7 +1,7 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
{% set moduleHeadline = headline is iterable ? headline.text|default('') : headline|default('') %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-grid">
{% if moduleHeadline %} {% if moduleHeadline %}
@@ -60,8 +60,8 @@
<div class="survey-grid"> <div class="survey-grid">
{% for editor in editors %} {% for editor in editors %}
<div> <div>
<strong>{{ [editor.firstname, editor.lastname]|join(' ')|trim ?: editor.email }}</strong><br> <strong>{{ ([editor.firstname, editor.lastname]|join(' ')|trim|striptags) ?: (editor.email|striptags|trim) }}</strong><br>
<span>{{ editor.email }}</span> <span>{{ editor.email|striptags|trim }}</span>
</div> </div>
{% else %} {% else %}
<div>Keine Editoren hinterlegt.</div> <div>Keine Editoren hinterlegt.</div>
@@ -135,8 +135,8 @@
{% if question.mandatory %}<span class="survey-badge warning">Pflicht</span>{% endif %} {% if question.mandatory %}<span class="survey-badge warning">Pflicht</span>{% endif %}
{% if loop.first %}<span class="survey-badge success">Start</span>{% endif %} {% if loop.first %}<span class="survey-badge success">Start</span>{% endif %}
</div> </div>
<h4>{{ question.question }}</h4> <h4>{{ question.question|striptags|trim }}</h4>
<p>{{ question.description ?: 'Keine Zusatzbeschreibung.' }}</p> <p>{{ (question.description|striptags|trim) ?: 'Keine Zusatzbeschreibung.' }}</p>
{% if not locked %} {% if not locked %}
<div class="survey-button-row"> <div class="survey-button-row">
<a class="survey-button secondary" href="?survey={{ survey.id }}&question={{ question.id }}">Bearbeiten</a> <a class="survey-button secondary" href="?survey={{ survey.id }}&question={{ question.id }}">Bearbeiten</a>
@@ -169,9 +169,9 @@
<div class="survey-grid"> <div class="survey-grid">
{% for condition in conditions %} {% for condition in conditions %}
<article class="survey-card"> <article class="survey-card">
<div><strong>Wenn Antwort = {{ condition.answerValue }}</strong></div> <div><strong>Wenn Antwort = {{ condition.answerValue|striptags|trim }}</strong></div>
<div>{{ condition.sourceQuestionLabel ?: ('Frage #' ~ condition.sourceQuestion) }}</div> <div>{{ (condition.sourceQuestionLabel|striptags|trim) ?: ('Frage #' ~ condition.sourceQuestion) }}</div>
<div>weiter zu {{ condition.targetQuestionLabel ?: ('Frage #' ~ condition.targetQuestion) }}</div> <div>weiter zu {{ (condition.targetQuestionLabel|striptags|trim) ?: ('Frage #' ~ condition.targetQuestion) }}</div>
{% if not locked %} {% if not locked %}
<div class="survey-button-row"> <div class="survey-button-row">
<a class="survey-button secondary" href="?survey={{ survey.id }}&condition={{ condition.id }}">Bearbeiten</a> <a class="survey-button secondary" href="?survey={{ survey.id }}&condition={{ condition.id }}">Bearbeiten</a>
@@ -1,7 +1,7 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
{% set moduleHeadline = headline is iterable ? headline.text|default('') : headline|default('') %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-grid">
{% if moduleHeadline %} {% if moduleHeadline %}
@@ -23,13 +23,13 @@
{% for survey in surveys %} {% for survey in surveys %}
<article class="survey-card"> <article class="survey-card">
<div class="survey-meta"> <div class="survey-meta">
<h3>{{ survey.title }}</h3> <h3>{{ survey.title|striptags|trim }}</h3>
<span class="survey-badge {{ survey.published ? 'success' : 'neutral' }}">{{ survey.published ? 'veroeffentlicht' : 'Entwurf' }}</span> <span class="survey-badge {{ survey.published ? 'success' : 'neutral' }}">{{ survey.published ? 'veroeffentlicht' : 'Entwurf' }}</span>
{% if survey.isLocked %} {% if survey.isLocked %}
<span class="survey-badge warning">gesperrt</span> <span class="survey-badge warning">gesperrt</span>
{% endif %} {% endif %}
</div> </div>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p> <p>{{ (survey.description|striptags|trim) ?: 'Keine Beschreibung hinterlegt.' }}</p>
<div class="survey-meta"> <div class="survey-meta">
<span class="survey-badge neutral">{{ survey.questionCount }} Fragen</span> <span class="survey-badge neutral">{{ survey.questionCount }} Fragen</span>
<span class="survey-badge neutral">{{ survey.answerCount }} Antworten</span> <span class="survey-badge neutral">{{ survey.answerCount }} Antworten</span>
@@ -1,7 +1,7 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
{% set moduleHeadline = headline is iterable ? headline.text|default('') : headline|default('') %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-grid">
{% if moduleHeadline %} {% if moduleHeadline %}
@@ -13,10 +13,10 @@
{% elseif survey %} {% elseif survey %}
<article class="survey-card"> <article class="survey-card">
<div class="survey-meta"> <div class="survey-meta">
<h3>{{ survey.title }}</h3> <h3>{{ survey.title|striptags|trim }}</h3>
{% if progress.total > 0 %}<span class="survey-badge neutral">{{ progress.current }}/{{ progress.total }}</span>{% endif %} {% if progress.total > 0 %}<span class="survey-badge neutral">{{ progress.current }}/{{ progress.total }}</span>{% endif %}
</div> </div>
<p>{{ survey.description ?: 'Bitte beantworten Sie die folgenden Fragen. Jede Frage wird einzeln angezeigt.' }}</p> <p>{{ (survey.description|striptags|trim) ?: 'Bitte beantworten Sie die folgenden Fragen. Jede Frage wird einzeln angezeigt.' }}</p>
{% if progress.total > 0 %} {% if progress.total > 0 %}
<div class="survey-progress"><span style="width: {{ progress.percentage }}%"></span></div> <div class="survey-progress"><span style="width: {{ progress.percentage }}%"></span></div>
@@ -32,8 +32,8 @@
{% for answer in answers %} {% for answer in answers %}
<article class="survey-card"> <article class="survey-card">
<div class="survey-badge neutral">{{ answer.questionType }}</div> <div class="survey-badge neutral">{{ answer.questionType }}</div>
<h4>{{ answer.question }}</h4> <h4>{{ answer.question|striptags|trim }}</h4>
<p>{{ answer.value }}</p> <p>{{ answer.value|striptags|trim }}</p>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
@@ -42,8 +42,8 @@
{% elseif question %} {% elseif question %}
<article class="survey-card"> <article class="survey-card">
<div class="survey-meta"><span class="survey-badge neutral">Frage {{ progress.current }}</span></div> <div class="survey-meta"><span class="survey-badge neutral">Frage {{ progress.current }}</span></div>
<h3>{{ question.question }}</h3> <h3>{{ question.question|striptags|trim }}</h3>
{% if question.description %}<p>{{ question.description }}</p>{% endif %} {% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %}
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
@@ -51,7 +51,7 @@
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));"> <div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
{% for child in surveyForm.answer %} {% for child in surveyForm.answer %}
<label class="survey-card" style="cursor: pointer;"> <label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label }}</strong>{{ form_widget(child) }}</div> <div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
<div>Diese Antwort wird direkt fuer den weiteren Ablauf verwendet.</div> <div>Diese Antwort wird direkt fuer den weiteren Ablauf verwendet.</div>
</label> </label>
{% endfor %} {% endfor %}
@@ -60,7 +60,7 @@
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));"> <div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
{% for child in surveyForm.answer %} {% for child in surveyForm.answer %}
<label class="survey-card" style="cursor: pointer;"> <label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label }}</strong>{{ form_widget(child) }}</div> <div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
@@ -1,7 +1,7 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
{% set moduleHeadline = headline is iterable ? headline.text|default('') : headline|default('') %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-grid">
{% if moduleHeadline %} {% if moduleHeadline %}
@@ -15,12 +15,12 @@
{% elseif survey %} {% elseif survey %}
<article class="survey-card"> <article class="survey-card">
<div class="survey-meta"> <div class="survey-meta">
<h2>{{ survey.title }}</h2> <h2>{{ survey.title|striptags|trim }}</h2>
<span class="survey-badge neutral">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span> <span class="survey-badge neutral">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span>
<span class="survey-badge neutral">{{ questionResults|length }} Fragen</span> <span class="survey-badge neutral">{{ questionResults|length }} Fragen</span>
</div> </div>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p> <p>{{ (survey.description|striptags|trim) ?: 'Keine Beschreibung hinterlegt.' }}</p>
{% if downloadUrl or pdfDownloadUrl %} {% if downloadUrl or pdfDownloadUrl %}
<div class="survey-button-row"> <div class="survey-button-row">
@@ -38,13 +38,13 @@
{% for question in questionResults %} {% for question in questionResults %}
<article class="survey-card survey-results-card"> <article class="survey-card survey-results-card">
<div class="survey-meta"> <div class="survey-meta">
<h3>{{ question.question }}</h3> <h3>{{ question.question|striptags|trim }}</h3>
<span class="survey-badge neutral">{{ question.totalAnswers }} Antworten</span> <span class="survey-badge neutral">{{ question.totalAnswers }} Antworten</span>
<span class="survey-badge neutral">{{ question.responseRate }}% Ruecklauf</span> <span class="survey-badge neutral">{{ question.responseRate }}% Ruecklauf</span>
</div> </div>
{% if question.description %} {% if question.description|striptags|trim %}
<p>{{ question.description }}</p> <p>{{ question.description|striptags|trim }}</p>
{% endif %} {% endif %}
<div class="survey-results-layout{% if not question.chart %} no-chart{% endif %}"> <div class="survey-results-layout{% if not question.chart %} no-chart{% endif %}">
@@ -62,7 +62,7 @@
{% for option in question.options %} {% for option in question.options %}
<div class="survey-results-row"> <div class="survey-results-row">
<div class="survey-meta"> <div class="survey-meta">
<strong>{{ option.label }}</strong> <strong>{{ option.label|striptags|trim }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span> <span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span> <span class="survey-badge neutral">{{ option.percentage }}%</span>
</div> </div>
@@ -92,7 +92,7 @@
{% for option in question.distribution %} {% for option in question.distribution %}
<div class="survey-results-row"> <div class="survey-results-row">
<div class="survey-meta"> <div class="survey-meta">
<strong>{{ option.label }}</strong> <strong>{{ option.label|striptags|trim }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span> <span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span> <span class="survey-badge neutral">{{ option.percentage }}%</span>
</div> </div>
@@ -110,7 +110,7 @@
{% for option in question.responseSummary %} {% for option in question.responseSummary %}
<div class="survey-results-row"> <div class="survey-results-row">
<div class="survey-meta"> <div class="survey-meta">
<strong>{{ option.label }}</strong> <strong>{{ option.label|striptags|trim }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span> <span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span> <span class="survey-badge neutral">{{ option.percentage }}%</span>
</div> </div>
@@ -124,7 +124,7 @@
<div class="survey-results-list survey-results-responses"> <div class="survey-results-list survey-results-responses">
{% for response in question.responses %} {% for response in question.responses %}
<blockquote class="survey-card survey-response-card">{{ response|nl2br }}</blockquote> <blockquote class="survey-card survey-response-card">{{ response|striptags|trim|nl2br }}</blockquote>
{% else %} {% else %}
<p>Noch keine Freitextantworten vorhanden.</p> <p>Noch keine Freitextantworten vorhanden.</p>
{% endfor %} {% endfor %}
@@ -2,7 +2,10 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ documentTitle|striptags|trim }}</title>
<style> <style>
{{ fontFaceCss|raw }}
@page { @page {
margin: 20mm 14mm 18mm; margin: 20mm 14mm 18mm;
} }
@@ -10,7 +13,7 @@
body { body {
margin: 0; margin: 0;
color: #222222; color: #222222;
font-family: DejaVu Sans, sans-serif; font-family: "PT Sans Narrow", sans-serif;
font-size: 11px; font-size: 11px;
line-height: 1.45; line-height: 1.45;
background: #ffffff; background: #ffffff;
@@ -33,6 +36,7 @@
.page-header h1 { .page-header h1 {
font-size: 22px; font-size: 22px;
font-family: "Blogger Sans", sans-serif;
color: #0f4f88; color: #0f4f88;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -64,6 +68,7 @@
.question-card h2 { .question-card h2 {
font-size: 16px; font-size: 16px;
font-family: "Blogger Sans", sans-serif;
color: #0f4f88; color: #0f4f88;
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -174,6 +179,7 @@
.metric-value { .metric-value {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
font-family: "Blogger Sans", sans-serif;
color: #0f4f88; color: #0f4f88;
} }
@@ -196,8 +202,8 @@
</head> </head>
<body> <body>
<header class="page-header"> <header class="page-header">
<h1>{{ survey.title }}</h1> <h1>{{ survey.title|striptags|trim }}</h1>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p> <p>{{ (survey.description|striptags|trim) ?: 'Keine Beschreibung hinterlegt.' }}</p>
<div class="meta-row"> <div class="meta-row">
<span class="meta-pill">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span> <span class="meta-pill">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span>
@@ -208,10 +214,10 @@
{% for question in questionResults %} {% for question in questionResults %}
<section class="question-card"> <section class="question-card">
<h2>{{ question.question }}</h2> <h2>{{ question.question|striptags|trim }}</h2>
{% if question.description %} {% if question.description|striptags|trim %}
<p class="question-description">{{ question.description }}</p> <p class="question-description">{{ question.description|striptags|trim }}</p>
{% endif %} {% endif %}
<div class="meta-row"> <div class="meta-row">
@@ -221,7 +227,7 @@
{% if question.pdfChartDataUri %} {% if question.pdfChartDataUri %}
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}"> <div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question }}"> <img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question|striptags|trim }}">
{% if question.type == 'range' and question.pdfChartLegend %} {% if question.type == 'range' and question.pdfChartLegend %}
<div class="chart-hint">Die Punkte sind farblich den Werten in der Tabelle zugeordnet.</div> <div class="chart-hint">Die Punkte sind farblich den Werten in der Tabelle zugeordnet.</div>
{% endif %} {% endif %}
@@ -242,7 +248,7 @@
{% for option in question.pdfChartLegend %} {% for option in question.pdfChartLegend %}
<tr> <tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td> <td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td> <td>{{ option.label|striptags|trim }}</td>
<td class="numeric">{{ option.count }}</td> <td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td> <td class="numeric">{{ option.percentage }}%</td>
</tr> </tr>
@@ -282,7 +288,7 @@
{% for option in question.pdfChartLegend %} {% for option in question.pdfChartLegend %}
<tr> <tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td> <td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td> <td>{{ option.label|striptags|trim }}</td>
<td class="numeric">{{ option.count }}</td> <td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td> <td class="numeric">{{ option.percentage }}%</td>
</tr> </tr>
@@ -308,7 +314,7 @@
{% for option in question.pdfChartLegend %} {% for option in question.pdfChartLegend %}
<tr> <tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td> <td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td> <td>{{ option.label|striptags|trim }}</td>
<td class="numeric">{{ option.count }}</td> <td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td> <td class="numeric">{{ option.percentage }}%</td>
</tr> </tr>
@@ -319,7 +325,7 @@
<ol class="response-list"> <ol class="response-list">
{% for response in question.responses %} {% for response in question.responses %}
<li>{{ response }}</li> <li>{{ response|striptags|trim }}</li>
{% else %} {% else %}
<li>Noch keine Freitextantworten vorhanden.</li> <li>Noch keine Freitextantworten vorhanden.</li>
{% endfor %} {% endfor %}
+37
View File
@@ -197,9 +197,46 @@ final class SurveyResultsPdfService
'completedSubmissionCount' => $completedSubmissionCount, 'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $questionResults, 'questionResults' => $questionResults,
'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'), 'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'),
'documentTitle' => $this->buildDocumentTitle((string) $survey->title),
'fontFaceCss' => $this->buildEmbeddedFontFaceCss(),
]); ]);
} }
private function buildDocumentTitle(string $surveyTitle): string
{
$title = trim(strip_tags($surveyTitle));
return '' !== $title ? $title.' Ergebnisse' : 'Umfrage Ergebnisse';
}
private function buildEmbeddedFontFaceCss(): string
{
$fontDefinitions = [
['family' => 'PT Sans Narrow', 'weight' => 400, 'path' => __DIR__.'/../../public/fonts/PTSansNarrow-Regular.woff2'],
['family' => 'PT Sans Narrow', 'weight' => 700, 'path' => __DIR__.'/../../public/fonts/PTSansNarrow-Bold.woff2'],
['family' => 'Blogger Sans', 'weight' => 500, 'path' => __DIR__.'/../../public/fonts/Blogger Sans-Medium.woff2'],
];
$rules = [];
foreach ($fontDefinitions as $fontDefinition) {
$fontContent = @file_get_contents($fontDefinition['path']);
if (false === $fontContent) {
continue;
}
$rules[] = sprintf(
'@font-face { font-family: "%s"; src: url("data:font/woff2;base64,%s") format("woff2"); font-weight: %d; font-style: normal; font-display: swap; }',
$fontDefinition['family'],
base64_encode($fontContent),
$fontDefinition['weight'],
);
}
return implode("\n", $rules);
}
/** /**
* @param array<string, mixed> $questionResult * @param array<string, mixed> $questionResult
*/ */