The back-to-top button was hidden as long as the consent question was open, on every device - and on a phone the question tends to stay open. Instead of `display:none` it now anchors itself 12px above the bar wherever the bar covers its corner: below 800px for the centred card, at every width for the full-width bar and the corner card. Browsers without anchor positioning keep hiding it there. The bar renders before the button so it can be an anchor, and the reserved bottom padding grows to 320px below 620px, where the bar with its default text is up to 299px tall. The notice itself gets a face. Settings → Einwilligung now carries layout (card, bar, corner), shape (soft, round, square - one setting for the box and its buttons), a heading and four colours, each as #hex or @palette key. `Theme::cssColor()` resolves both, replacing the copy inside the back-to-top code; `Consent::styles()` writes the values as `--kant-consent-*` into the hashed style block. The base layout changes with it: an accent stripe and shield icon, equal-width buttons, a disclosure with a chevron, underlined legal links, a two-column bar on wide screens. Declining stays an outlined button of the same size next to accepting. The archive and the consent page carry the same variables - the archive had no palette at all until now. In the editor, rows with an own value are labelled in orange rather than bold with a dot; the device chips sit 6px apart with the focus ring inside the gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
211 lines
7.6 KiB
PHP
211 lines
7.6 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);
|
|
|
|
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).'}';
|
|
}
|
|
}
|