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>
108 lines
8.1 KiB
Twig
108 lines
8.1 KiB
Twig
{% set type = block.type %}
|
|
{% set props = block.props|default({}) %}
|
|
{% set layout = block.layout|default({}) %}
|
|
{% set designClasses = '' %}
|
|
{% for device, settings in layout.design|default({}) %}
|
|
{% for key, value in settings %}{% set designClasses = designClasses ~ ' d-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}
|
|
{% endfor %}
|
|
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
|
|
{% if type in ['section', 'row', 'column'] %}
|
|
{% set tag = type == 'section' ? 'section' : 'div' %}
|
|
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
|
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
{% if type == 'section' %}</div>{% endif %}
|
|
</{{ tag }}>
|
|
{% elseif type == 'heading' %}
|
|
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
|
|
{% elseif type == 'text' %}
|
|
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
|
{% elseif type == 'button' %}
|
|
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
|
{% elseif type == 'image' %}
|
|
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
|
{% elseif type == 'iconcard' %}
|
|
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
|
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
|
<h3>{% if props.href|default('') %}<a href="{{ props.href }}">{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
|
</div>
|
|
{% elseif type == 'stat' %}
|
|
<p id="{{ block.id }}" class="block-stat{{ designClasses }}"><strong>{{ props.value }}</strong><span>{{ props.label }}</span></p>
|
|
{% elseif type == 'quote' %}
|
|
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
|
|
{% if props.rating|default(0) > 0 %}
|
|
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p>
|
|
{% endif %}
|
|
<blockquote><p>{{ props.text }}</p></blockquote>
|
|
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
|
|
</figure>
|
|
{% elseif type == 'logos' %}
|
|
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
|
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}</li>{% endfor %}
|
|
</ul>
|
|
{% elseif type == 'cta' %}
|
|
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
|
<div>
|
|
<h2>{{ props.title }}</h2>
|
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
|
</div>
|
|
<a class="button" href="{{ props.href }}">{{ props.buttonText }}</a>
|
|
</div>
|
|
{% elseif type == 'contact' %}
|
|
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
|
{% if props.phone|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'phone'} only %}<a href="tel:{{ props.phone|replace({' ': '', '(': '', ')': '', '/': '', '-': ''}) }}">{{ props.phone }}</a></span>{% endif %}
|
|
{% if props.email|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'mail'} only %}<a href="mailto:{{ props.email }}">{{ props.email }}</a></span>{% endif %}
|
|
{% if props.address|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'pin'} only %}{{ props.address }}</span>{% endif %}
|
|
</address>
|
|
{% elseif type == 'divider' %}
|
|
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
|
|
{% elseif type == 'spacer' %}
|
|
<div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div>
|
|
{% elseif type == 'form' %}
|
|
<div id="{{ block.id }}" class="block-formwrap{{ designClasses }}">
|
|
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
|
|
</div>
|
|
{% elseif type == 'tabs' %}
|
|
{# Without the script this is an anchor list followed by every panel: complete and readable.
|
|
tabs.js turns it into a tab widget; it only changes presentation, never content. #}
|
|
<div id="{{ block.id }}" class="block-tabs{{ designClasses }}" data-tabs>
|
|
<ul class="tabs-nav">
|
|
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
|
</ul>
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
</div>
|
|
{% elseif type == 'tabitem' %}
|
|
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
|
|
<h3 class="tabs-heading">{{ props.title }}</h3>
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
</section>
|
|
{% elseif type == 'accordion' %}
|
|
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
</div>
|
|
{% elseif type == 'accordionitem' %}
|
|
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
|
|
<summary>{{ props.title }}</summary>
|
|
<div class="accordion-body">
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% elseif type == 'postlist' %}
|
|
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
|
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %}
|
|
</div>
|
|
{% elseif type == 'gallery' %}
|
|
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
{#
|
|
A block whose plugin is not available. Its stored content stays in the document; the core
|
|
renders nothing from its unvalidated props. Visitors see no broken fragment, while the
|
|
editor preview shows a placeholder so the block can still be found and moved.
|
|
#}
|
|
<!-- Block {{ block.id }} ({{ type }}) wird von dieser Installation nicht dargestellt. -->
|
|
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable" role="note">Baustein „{{ type }}“ ist in dieser Installation nicht verfügbar. Der Inhalt bleibt gespeichert.</p>{% endif %}
|
|
{% endif %}
|