Files
KantSites/src/Content/Consent.php
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00

214 lines
7.9 KiB
PHP

<?php
namespace App\Content;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The visitor's decision about groups of content that may not load without consent.
*
* Two things matter here, and the banner is the smaller one. The banner only asks; what makes the
* answer worth anything is that an embed does not load until it has been given - that is why
* `granted()` is asked in the renderer and not only in the template.
*
* The decision lives in one first-party cookie and nowhere else: no server record, no identifier,
* no third party. The cookie holds the version of the question, the groups that were granted and
* when - nothing about the person. It is deliberately not signed: it describes what this browser
* chose, so a forged value can only change what that same browser gets shown.
*/
final class Consent
{
public const COOKIE = 'kant_consent';
/**
* Necessary content is not a group: it is what remains when nothing is granted, and it is
* never asked about. Only these two can be asked about.
*/
public const GROUPS = [
'statistics' => 'Statistik',
'external' => 'Externe Inhalte',
];
public const GROUP_HELP = [
'statistics' => 'Messung, wie die Website benutzt wird.',
'external' => 'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.',
];
/**
* Half a year, then the question is asked again. Long enough not to be a nuisance, short
* enough that a decision is not silently eternal.
*/
public const MAX_AGE = 15552000;
/** Raise this when the groups or their meaning change: an old answer then no longer counts. */
public const VERSION = 1;
public function __construct(private SiteSettings $settings)
{
}
/** Which groups this site asks about at all. Empty means: nothing to consent to. */
public function askedGroups(): array
{
$settings = $this->settings->load();
if (!$settings['consentBanner']) {
return [];
}
return array_values(array_intersect(array_keys(self::GROUPS), $settings['consentGroups']));
}
/**
* The stored decision, or null when there is none that still counts.
*
* @return array{granted: list<string>, at: int}|null
*/
public function decision(Request $request): ?array
{
$raw = $request->cookies->get(self::COOKIE);
if (!is_string($raw) || strlen($raw) > 200) {
return null;
}
$parts = explode('|', $raw);
if (3 !== count($parts) || (string) self::VERSION !== $parts[0]) {
return null;
}
$at = filter_var($parts[2], FILTER_VALIDATE_INT);
if (false === $at || $at <= 0 || $at + self::MAX_AGE < time()) {
return null;
}
$granted = array_values(array_intersect(
array_keys(self::GROUPS),
'' === $parts[1] ? [] : explode(',', $parts[1]),
));
return ['granted' => $granted, 'at' => $at];
}
/**
* Whether a group may load.
*
* A group this site never asks about counts as granted: otherwise switching the banner off
* would silently hide content that needs no consent.
*/
public function granted(Request $request, string $group): bool
{
if (!in_array($group, $this->askedGroups(), true)) {
return true;
}
return in_array($group, $this->decision($request)['granted'] ?? [], true);
}
/** Whether this request should be shown the question. */
public function needsBanner(Request $request): bool
{
return [] !== $this->askedGroups() && null === $this->decision($request);
}
/** @param list<string> $groups */
public function remember(Request $request, Response $response, array $groups): void
{
$granted = array_values(array_intersect($this->askedGroups(), $groups));
$response->headers->setCookie(Cookie::create(
self::COOKIE,
sprintf('%d|%s|%d', self::VERSION, implode(',', $granted), time()),
time() + self::MAX_AGE,
'/',
null,
$request->isSecure(),
// No script needs to read it: the banner is rendered by the server.
true,
false,
Cookie::SAMESITE_LAX,
));
}
public function forget(Response $response): void
{
$response->headers->clearCookie(self::COOKIE, '/');
}
/**
* The two mandatory pages, as link path => label, for the note every page carries.
*
* It sits in this class because the templates already have it at hand, and because the note
* belongs next to the consent link: both are things a visitor must be able to reach from
* anywhere, not things an editor can delete out of the footer by accident.
*
* @return array<string, string>
*/
public function legalLinks(): array
{
$settings = $this->settings->load();
$links = [];
foreach (['privacyPath' => 'Datenschutz', 'imprintPath' => 'Impressum'] as $key => $label) {
if ('' !== $settings[$key]) {
$links[$settings[$key]] = $label;
}
}
return $links;
}
/** What the banner and the consent page need to render. */
public function state(Request $request): array
{
$settings = $this->settings->load();
$decision = $this->decision($request);
// Auf einer Seite in einer weiteren Sprache der Hinweis in dieser Sprache (ADR 111).
$locale = $request->attributes->get('kant_locale');
$translated = is_string($locale) ? ($settings['consentTexts'][$locale] ?? []) : [];
return [
'groups' => array_map(
static fn (string $group): array => [
'key' => $group,
'name' => self::GROUPS[$group],
'help' => self::GROUP_HELP[$group],
'granted' => in_array($group, $decision['granted'] ?? [], true),
],
$this->askedGroups(),
),
'decided' => null !== $decision,
'decidedAt' => $decision['at'] ?? null,
'text' => $translated['text'] ?? $settings['consentText'],
'title' => $translated['title'] ?? $settings['consentTitle'],
'layout' => $settings['consentLayout'],
'privacyPath' => $settings['privacyPath'],
'imprintPath' => $settings['imprintPath'],
];
}
/**
* Die Gestaltung des Hinweises als CSS-Variablen für den Kopf der Seite.
*
* Das Stylesheet kennt die Standardwerte; hier stehen nur die Abweichungen, die jemand in den
* Einstellungen gewählt hat. Die Form bestimmt zwei Radien auf einmal - Fläche und Knöpfe
* gehören zusammen, und zwei Regler dafür wären zwei Regler, die man gegeneinander verstellt.
*/
public function styles(array $theme): string
{
$settings = $this->settings->load();
if (!$settings['consentBanner']) {
return '';
}
[$box, $button] = match ($settings['consentShape']) {
'rund' => ['28px', '999px'],
'eckig' => ['0', '0'],
default => ['18px', '10px'],
};
$values = ['--kant-consent-radius:'.$box, '--kant-consent-button-radius:'.$button];
foreach (['consentColor' => '--kant-consent-bg', 'consentInk' => '--kant-consent-ink', 'consentAccent' => '--kant-consent-accent', 'consentAccentInk' => '--kant-consent-accent-ink'] as $field => $variable) {
$value = Theme::cssColor((string) $settings[$field], $theme);
if (null !== $value) {
$values[] = $variable.':'.$value;
}
}
return ':root{'.implode(';', $values).'}';
}
}