Migration 20260912000700 legt form und form_submission an. Der Formularblock im Seiteninhalt enthält nur die Adresse: Felder, Empfänger und Einwilligungstext liegen in der Datenbank, damit ein gespeichertes Dokument nicht ändern kann, wonach gefragt wird oder wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über aria-describedby und aria-invalid; eine Ablehnung rendert die Seite mit HTTP 422 und allen Eingaben neu, eine gültige Absendung wird mit HTTP 303 zurückgeleitet. Alles ohne JavaScript. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel gegen zu schnelle und zu alte Absendungen, fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg. Die Absenderadresse wird nie gespeichert, nur ein Hash. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert. Kein CSRF-Token auf dem öffentlichen Weg, weil das eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite bedeuten würde; Begründung in ADR 017. Dateiuploads fehlen bewusst. Ein echter Fehler von den Tests gefunden: das Stundenfenster des Limits entstand aus lokaler Zeit, die Zeitstempel aber aus UTC - auf einer Uhr vor UTC hätte das Limit nie gegriffen. Geprüft: 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container, composer audit. Der E-Mail-Versand wurde nie wirklich ausgeführt; keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57 lines
4.0 KiB
Twig
57 lines
4.0 KiB
Twig
{# Server rendered, no JavaScript. Errors are announced and each message sits at its field. #}
|
|
{% if form is null %}
|
|
<p class="form-missing">Dieses Formular ist nicht mehr vorhanden.</p>
|
|
{% elseif sent %}
|
|
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
|
{% else %}
|
|
<form class="block-form" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
|
<input type="hidden" name="page" value="{{ page.path }}">
|
|
<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 is not empty %}
|
|
<p class="form-error-summary" role="alert">Bitte prüfe {{ errors|length }} {{ errors|length == 1 ? 'Eingabe' : 'Eingaben' }}. Die betroffenen Felder sind unten gekennzeichnet.</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' : '') %}
|
|
<div class="form-field{{ error ? ' has-error' : '' }}">
|
|
{% if field.type == 'consent' %}
|
|
<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>
|
|
{% else %}
|
|
<label for="{{ id }}">{{ field.label }}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> (Pflichtfeld)</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 —</option>
|
|
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ option }}</option>{% endfor %}
|
|
</select>
|
|
{% 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 %}
|
|
{% 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>
|
|
{% endfor %}
|
|
<button class="button" type="submit">Absenden</button>
|
|
</form>
|
|
{% endif %}
|