'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, 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 $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 */ 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); 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' => $settings['consentText'], '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).'}'; } }