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>
214 lines
7.9 KiB
PHP
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).'}';
|
|
}
|
|
}
|