Files
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00

114 lines
11 KiB
Twig

{# Server rendered, no JavaScript needed. Errors are announced and each message sits at its field.
Schritte sind fieldsets: ohne Skript ein langes Formular mit Abschnitten, mit form-steps.js ein
Schritt nach dem anderen. Bedingungen stehen als Datenattribute am Feld; der Server prüft sie
beim Absenden selbst und verlangt nichts, was gar nicht galt. #}
{% if form is null %}
<p class="form-missing">{{ 'Dieses Formular ist nicht mehr vorhanden.'|kant_t }}</p>
{% elseif sent %}
<p class="form-success" role="status">{{ form.settings.success|kant_t }}</p>
{% else %}
{% set hasSteps = form.fields|filter(f => f.type == 'step')|length > 0 %}
<form class="block-form{{ hasSteps ? ' form-stepped' : '' }}" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate{% if hasSteps %} data-steps{% endif %}{% if form.settings.summary|default(false) %} data-summary{% endif %}{% if form.locale|default(null) %} data-texts="{{ {summary: 'Zusammenfassung'|kant_t, summaryIntro: 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'|kant_t, steps: 'Schritte des Formulars'|kant_t, step: 'Schritt'|kant_t, back: 'Zurück'|kant_t, next: 'Weiter'|kant_t, change: 'Ändern'|kant_t, yes: 'Ja'|kant_t, chooseOne: 'Bitte wähle mindestens eine Option.'|kant_t}|json_encode|e('html_attr') }}"{% endif %}>
<input type="hidden" name="page" value="{{ page.path }}">
{% if form.locale|default(null) %}<input type="hidden" name="language" value="{{ form.locale }}">{% endif %}
<input type="hidden" name="block" value="{{ blockId }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="issuedSignature" value="{{ issuedSignature }}">
{# Bait for automated senders. Hidden from view, from assistive technology and from tab order. #}
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
{% if errors._form is defined %}
<p class="form-error-summary" role="alert">{{ errors._form|kant_t }}</p>
{% elseif errors is not empty %}
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
{% endif %}
{% for field in form.fields %}
{% set id = blockId ~ '-' ~ field.name %}
{% set error = errors[field.name]|default(null) %}
{% set value = values[field.name]|default('') %}
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
{% set condition %}{% if field.showIf|default(null) %} data-show-if="{{ field.showIf.field }}" data-show-values="{{ field.showIf.values|join('|') }}"{% endif %}{% endset %}
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> ({{ 'Pflichtfeld'|kant_t }})</span>{% endif %}{% endset %}
{% if field.type == 'step' %}
{% if not loop.first and form.fields[0:loop.index0]|filter(f => f.type == 'step')|length > 0 %}</fieldset>{% endif %}
<fieldset class="form-step" data-step{{ condition }}>
<legend class="form-step-title" tabindex="-1">{{ field.label }}</legend>
{% if field.help %}<p class="form-step-intro">{{ field.help }}</p>{% endif %}
{% elseif field.type == 'consent' %}
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
<div class="check-row">
<input id="{{ id }}" name="{{ field.name }}" type="checkbox" value="ja"{{ value ? ' checked' : '' }} required
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
<label for="{{ id }}">{{ field.label }} <span class="required-mark" aria-hidden="true">*</span></label>
</div>
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
</div>
{% elseif field.type == 'radio' or field.type == 'checkboxes' %}
{# Eine Gruppe: die Beschriftung ist die legend, jede Option hat ihr eigenes Feld. Bei
der Mehrfachauswahl steht required nicht am Kästchen - das hieße „alle ankreuzen“ -
sondern an der Gruppe; Skript und Server prüfen es dort. #}
{# Hat eine Option ein Bild, wird die ganze Auswahl zur Bildauswahl: jede Option eine
Karte mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. #}
{% set bildauswahl = field.images|default({})|length > 0 %}
<fieldset class="form-field form-choice{{ bildauswahl ? ' form-choice-images' : '' }}{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}{% if field.type == 'checkboxes' and field.required %} data-required{% endif %}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}>
<legend>{{ field.label }}{{ requiredMark }}</legend>
{% set picked = field.type == 'checkboxes' ? value|split(', ') : [value] %}
{% if bildauswahl %}<div class="form-choice-grid">{% endif %}
{% for option in field.options %}
{% set bild = optionImages[field.images[option]|default('')]|default(null) %}
<div class="{{ bildauswahl ? 'form-choice-card' : 'check-row' }}">
<input id="{{ id }}-{{ loop.index }}" name="{{ field.name }}{{ field.type == 'checkboxes' ? '[]' : '' }}" type="{{ field.type == 'checkboxes' ? 'checkbox' : 'radio' }}" value="{{ option }}"{{ option in picked ? ' checked' : '' }}{{ field.type == 'radio' and field.required ? ' required' : '' }}{{ error ? ' aria-invalid="true"' : '' }}>
<label for="{{ id }}-{{ loop.index }}">{% if bildauswahl %}<span class="form-choice-picture">{% if bild %}<img src="{{ bild.src }}" alt="" width="{{ bild.width }}" height="{{ bild.height }}" loading="lazy" decoding="async">{% endif %}</span><span class="form-choice-text">{{ field.optionLabels[option]|default(option) }}</span>{% else %}{{ field.optionLabels[option]|default(option) }}{% endif %}</label>
</div>
{% endfor %}
{% if bildauswahl %}</div>{% endif %}
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
</fieldset>
{% else %}
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> {{ 'in'|kant_t }} {{ field.unit }}</span>{% endif %}</label>
{% if field.type == 'textarea' %}
<textarea id="{{ id }}" name="{{ field.name }}" rows="6" maxlength="5000"{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>{{ value }}</textarea>
{% elseif field.type == 'select' %}
<select id="{{ id }}" name="{{ field.name }}"{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
<option value="">— {{ 'bitte wählen'|kant_t }} —</option>
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ field.optionLabels[option]|default(option) }}</option>{% endfor %}
</select>
{% elseif field.type == 'number' %}
<div class="form-number{{ field.unit|default('') ? ' has-unit' : '' }}">
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" type="number" step="any" inputmode="decimal"
{% if field.min is not null %}min="{{ field.min }}"{% endif %} {% if field.max is not null %}max="{{ field.max }}"{% endif %}
{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
{% if field.unit|default('') %}<span class="form-unit" aria-hidden="true">{{ field.unit }}</span>{% endif %}
</div>
{% else %}
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" maxlength="200"
type="{{ {email: 'email', phone: 'tel'}[field.type]|default('text') }}"
autocomplete="{{ {email: 'email', phone: 'tel'}[field.type]|default('on') }}"
{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
{% endif %}
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
</div>
{% endif %}
{% endfor %}
{% if hasSteps %}</fieldset>{% endif %}
{% if turnstileKey|default('') %}
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="{{ form.locale|default('de') }}"></div>
<noscript><p class="field-error">{{ 'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.'|kant_t }}</p></noscript>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden')|kant_t }}</button>
</form>
{% endif %}