From 81d0f0cc0bf9fe4de902841b966d5dd108d334d1 Mon Sep 17 00:00:00 2001 From: florianthepro Date: Tue, 4 Aug 2026 11:41:57 +0200 Subject: [PATCH] Add files via upload --- v2index.php | 5714 ++++++++++++++++++++++++++++++++++++++++++++ v2indexsplit.zip | Bin 0 -> 86464 bytes v2minimal.php | 5430 +++++++++++++++++++++++++++++++++++++++++ v2minimalsplit.zip | Bin 0 -> 84174 bytes 4 files changed, 11144 insertions(+) create mode 100644 v2index.php create mode 100644 v2indexsplit.zip create mode 100644 v2minimal.php create mode 100644 v2minimalsplit.zip diff --git a/v2index.php b/v2index.php new file mode 100644 index 0000000..8d145ca --- /dev/null +++ b/v2index.php @@ -0,0 +1,5714 @@ +PHP-Version zu alt' + . '' + . '

PHP-Version zu alt

Diese Anwendung benötigt PHP 8.0 oder neuer (gefunden: ' + . htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8') + . '). Bitte im Verwaltungsbereich des Hosters umstellen.

'; + exit; +} + +const SW_CONFIG = [ + 'app_name' => 'Bürgerabstimmung', // Name in Titel und Fußzeile + 'domain' => '', // leer = Host der Anfrage, sonst feste Domain für erzeugte Adressen + + 'show_test_banner' => true, // true/false: gelbes Band „Testbetrieb“ ganz oben + + 'testmode_end' => 'gui', // gui = Umschalten unter /setup erlaubt, edit = nur hier, live = Testbetrieb sofort beenden + + 'eid_mode' => 'demo', // demo = Anmeldung über die Trust-Liste, eid = nur über eigenen eID-Server + + 'authorized_keys_url' => '', // https-Adresse der Trust-Liste mit freigegebenen Ausweis-Schlüsseln, leer = keine + + 'eid_client_url' => 'http://127.0.0.1:24727/eID-Client', // Ausweis-App auf dem Gerät (BSI TR-03124) + + 'eid_server_url' => '', // https-SOAP-Adresse des eigenen eID-Servers (BSI TR-03130) + 'eid_server_cert' => '', // Pfad zum Client-Zertifikat für den eID-Server + 'eid_server_key' => '', // Pfad zum privaten Schlüssel dazu + 'eid_providers' => [ // Anmelde-Knöpfe auf /auth; start = https-Startadresse, leer = Knopf meldet „nicht eingerichtet“ + 'ausweisapp' => ['label' => 'AusweisApp', 'start' => ''], + 'nect' => ['label' => 'Nect Wallet', 'start' => ''], + ], + + 'timezone' => 'Europe/Berlin', // Zeitzone aller angezeigten Daten + 'default_lang' => 'de', // Sprache vor der Auswahl: de oder en + 'langs' => ['de', 'en'], // wählbare Sprachen + + 'jury_share' => 0.01, // Anteil der Nutzer, der je Meldung ausgelost wird + 'jury_min' => 5, // angestrebte Mindestgröße der Jury + 'quorum_share' => 0.005, // Anteil der Nutzer, der für eine gültige Entscheidung stimmen muss + 'quorum_min' => 3, // angestrebte Mindestzahl abgegebener Jurystimmen + 'report_vote_hours' => 24, // Stunden, die eine Jury zum Entscheiden hat + 'jury_cooldown_days' => 3, // Tage, die jemand nach einem Dienst nicht erneut gelost wird + 'reports_per_day' => 3, // Meldungen, die eine Person pro Tag abgeben darf + + 'session_idle_minutes' => 30, // Minuten ohne Aufruf bis zur Abmeldung + 'session_max_hours' => 8, // Stunden bis zur Abmeldung, auch bei Betrieb + + 'page_size' => 20, // Themen je Seite +]; + +final class SW +{ + public static array $cfg = SW_CONFIG; + public static ?Db $db = null; + public static string $dataDir = ''; + public static string $pepper = ''; + public static string $serverSign = ''; + public static ?bool $testMode = null; + public static string $lang = 'de'; + + public static array $tActive = []; + public static ?array $user = null; + public static string $base = ''; + public static string $path = '/'; +} + +final class Clock +{ + public const FORMAT = 'Y-m-d H:i:s'; + private static ?DateTimeImmutable $testNow = null; + private static string $tz = 'Europe/Berlin'; + + public static function setTimezone(string $tz): void + { + self::$tz = $tz; + } + + public static function setTestNow(?DateTimeImmutable $now): void + { + self::$testNow = $now === null ? null : $now->setTimezone(new DateTimeZone('UTC')); + } + + public static function now(): DateTimeImmutable + { + return self::$testNow ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + + public static function nowStr(): string + { + return self::now()->format(self::FORMAT); + } + + public static function localDate(): string + { + return self::now()->setTimezone(new DateTimeZone(self::$tz))->format('Y-m-d'); + } + + public static function nextLocalMidnightUtcStr(): string + { + $local = self::now()->setTimezone(new DateTimeZone(self::$tz)); + return $local->modify('tomorrow')->setTime(0, 0, 0) + ->setTimezone(new DateTimeZone('UTC'))->format(self::FORMAT); + } + + public static function addHoursStr(string $utc, int $hours): string + { + return self::fromStr($utc)->modify(sprintf('%+d hours', $hours))->format(self::FORMAT); + } + + public static function addDaysStr(string $utc, int $days): string + { + return self::fromStr($utc)->modify(sprintf('%+d days', $days))->format(self::FORMAT); + } + + public static function fromStr(string $utc): DateTimeImmutable + { + return new DateTimeImmutable($utc, new DateTimeZone('UTC')); + } + + public static function displayLocal(string $utc, string $format): string + { + return self::fromStr($utc)->setTimezone(new DateTimeZone(self::$tz))->format($format); + } +} + +const SW_SCHEMA = <<<'SQL' +CREATE TABLE IF NOT EXISTS schema_info ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pseudonym_hash TEXT NOT NULL UNIQUE, + lang TEXT NOT NULL DEFAULT 'de' CHECK (lang IN ('de','en')), + is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0,1)), + is_seed INTEGER NOT NULL DEFAULT 0 CHECK (is_seed IN (0,1)), + jury_cooldown_until TEXT, + created_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name_de TEXT NOT NULL, + name_en TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + goal TEXT NOT NULL, + reasoning TEXT NOT NULL, + category_id INTEGER NOT NULL REFERENCES categories(id), + scope_level TEXT NOT NULL CHECK (scope_level IN ('kommune','landkreis','bundesland','bund')), + scope_name TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','closed','removed','archived')), + end_mode TEXT NOT NULL DEFAULT 'date' CHECK (end_mode IN ('date','count','both')), + end_date TEXT, + end_target INTEGER, + created_at TEXT NOT NULL, + created_date TEXT NOT NULL, + UNIQUE (author_id, created_date) +); +CREATE INDEX IF NOT EXISTS ix_topics_status_created ON topics(status, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_topics_category ON topics(category_id); +CREATE INDEX IF NOT EXISTS ix_topics_scope ON topics(scope_level, scope_name); + +CREATE TABLE IF NOT EXISTS votes ( + topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, + voter_tag TEXT NOT NULL, + choice TEXT NOT NULL CHECK (choice IN ('for','against')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (topic_id, voter_tag) +); +CREATE TABLE IF NOT EXISTS favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category','scope','topic')), + ref TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, ref) +); +CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, + reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + criteria TEXT NOT NULL, + freetext TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','voting','decided_removed','decided_kept')), + jury_size INTEGER NOT NULL, + quorum INTEGER NOT NULL, + created_at TEXT NOT NULL, + voting_starts_at TEXT NOT NULL, + decided_at TEXT, + UNIQUE (topic_id, reporter_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS ux_reports_open + ON reports(topic_id) WHERE status IN ('pending','voting'); +CREATE INDEX IF NOT EXISTS ix_reports_status ON reports(status); +CREATE TABLE IF NOT EXISTS report_jurors ( + report_id INTEGER NOT NULL REFERENCES reports(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote TEXT CHECK (vote IN ('confirm','reject','neutral')), + voted_at TEXT, + PRIMARY KEY (report_id, user_id) +); +CREATE INDEX IF NOT EXISTS ix_jurors_user ON report_jurors(user_id); +CREATE TABLE IF NOT EXISTS rate_limits ( + k TEXT PRIMARY KEY, + window_start INTEGER NOT NULL, + cnt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS eid_flows ( + nonce TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + eid_ref TEXT, + created_at INTEGER NOT NULL +); +SQL; + +final class Db +{ + private PDO $pdo; + + public function __construct(string $path) + { + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + throw new RuntimeException('Datenverzeichnis nicht anlegbar.'); + } + $this->pdo = new PDO('sqlite:' . $path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]); + $this->pdo->exec('PRAGMA journal_mode = WAL'); + $this->pdo->exec('PRAGMA foreign_keys = ON'); + $this->pdo->exec('PRAGMA busy_timeout = 5000'); + $this->pdo->exec('PRAGMA synchronous = NORMAL'); + } + + public function run(string $sql, array $params = []): PDOStatement + { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $type = is_int($value) ? PDO::PARAM_INT : (is_null($value) ? PDO::PARAM_NULL : PDO::PARAM_STR); + $stmt->bindValue(is_int($key) ? $key + 1 : $key, $value, $type); + } + $stmt->execute(); + return $stmt; + } + + public function one(string $sql, array $params = []): ?array + { + $row = $this->run($sql, $params)->fetch(); + return $row === false ? null : $row; + } + + public function all(string $sql, array $params = []): array + { + return $this->run($sql, $params)->fetchAll(); + } + + public function val(string $sql, array $params = []) + { + $v = $this->run($sql, $params)->fetchColumn(); + return $v === false ? null : $v; + } + + public function lastId(): int + { + return (int) $this->pdo->lastInsertId(); + } + + public function tx(callable $fn) + { + if ($this->pdo->inTransaction()) { + return $fn(); + } + $this->pdo->beginTransaction(); + try { + $result = $fn(); + $this->pdo->commit(); + return $result; + } catch (Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } + + public function migrate(): void + { + + $exists = $this->val("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_info'"); + $this->pdo->exec(SW_SCHEMA); + if ($exists === null) { + $this->run("INSERT INTO schema_info (k, v) VALUES ('version', '1'), ('created_at', ?)", [Clock::nowStr()]); + } + $favSql = (string) ($this->val("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'favorites'") ?? ''); + if ($favSql !== '' && strpos($favSql, "'topic'") === false) { + $this->pdo->exec( + "CREATE TABLE favorites_new ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category','scope','topic')), + ref TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, ref) + ); + INSERT INTO favorites_new SELECT user_id, kind, ref, created_at FROM favorites; + DROP TABLE favorites; + ALTER TABLE favorites_new RENAME TO favorites;" + ); + } + } +} + +const SW_HTACCESS_DENY = <<<'TXT' + + Require all denied + + + Order allow,deny + Deny from all + +TXT; + +const SW_HTACCESS_ROOT = <<<'TXT' + +Options -Indexes -MultiViews +DirectoryIndex index.php + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + + Require all denied + + + Order allow,deny + Deny from all + + + + + Require all denied + + + Order allow,deny + Deny from all + + +TXT; + +function sw_setup(): void +{ + Clock::setTimezone((string) SW::$cfg['timezone']); + date_default_timezone_set('UTC'); + error_reporting(E_ALL); + ini_set('display_errors', '0'); + ini_set('log_errors', '1'); + + SW::$dataDir = __DIR__ . '/data'; + if (!is_dir(SW::$dataDir) && !mkdir(SW::$dataDir, 0750, true) && !is_dir(SW::$dataDir)) { + throw new RuntimeException('Verzeichnis data/ nicht anlegbar.'); + } + ini_set('error_log', SW::$dataDir . '/php-error.log'); + + $dataHt = SW::$dataDir . '/.htaccess'; + if (!is_file($dataHt)) { + @file_put_contents($dataHt, SW_HTACCESS_DENY . "\n", LOCK_EX); + } + + $rootHt = __DIR__ . '/.htaccess'; + if (!is_file($rootHt)) { + @file_put_contents($rootHt, SW_HTACCESS_ROOT . "\n", LOCK_EX); + } + $robots = __DIR__ . '/robots.txt'; + if (!is_file($robots)) { + @file_put_contents($robots, "User-agent: *\nDisallow: /\n", LOCK_EX); + } + + $keyFile = SW::$dataDir . '/secret.key'; + if (!is_file($keyFile)) { + if (file_put_contents($keyFile, bin2hex(random_bytes(32)), LOCK_EX) === false) { + throw new RuntimeException('Geheimnis-Datei nicht schreibbar.'); + } + @chmod($keyFile, 0600); + } + $pepper = trim((string) file_get_contents($keyFile)); + if (strlen($pepper) < 32) { + throw new RuntimeException('Geheimnis-Datei beschädigt.'); + } + SW::$pepper = $pepper; + + if (card_supports_sodium()) { + $srvFile = SW::$dataDir . '/server_sign.key'; + if (!is_file($srvFile)) { + $pair = sodium_crypto_sign_keypair(); + if (file_put_contents($srvFile, base64_encode(sodium_crypto_sign_secretkey($pair)), LOCK_EX) === false) { + throw new RuntimeException('Server-Signaturschlüssel nicht schreibbar.'); + } + @chmod($srvFile, 0600); + } + SW::$serverSign = base64_decode(trim((string) file_get_contents($srvFile)), true) ?: ''; + } + + setup_apply(setup_load()); + + $dbPath = getenv('BUERGERABSTIMMUNG_DB') ?: SW::$dataDir . '/buergerabstimmung.sqlite'; + SW::$db = new Db($dbPath); + SW::$db->migrate(); + sw_seed_categories(); + + if ((string) SW::$cfg['testmode_end'] === 'live' && test_mode()) { + test_mode_end(); + } +} + +const SW_SETUP_KEYS = [ + 'eid_mode', 'eid_server_url', 'eid_server_cert', 'eid_server_key', + 'eid_client_url', 'authorized_keys_url', 'nect_start', +]; + +function setup_file(): string +{ + return SW::$dataDir . '/config.yaml'; +} + +function setup_token_file(): string +{ + return SW::$dataDir . '/setup.token'; +} + +function setup_token(): string +{ + $file = setup_token_file(); + if (!is_file($file)) { + @file_put_contents($file, bin2hex(random_bytes(16)) . "\n", LOCK_EX); + @chmod($file, 0600); + } + return trim((string) @file_get_contents($file)); +} + +function setup_load(): array +{ + $file = setup_file(); + if (!is_file($file)) { + return []; + } + $out = []; + foreach (explode("\n", (string) file_get_contents($file)) as $line) { + if (preg_match('/^([a-z_]+):\s*"(.*)"\s*$/', trim($line), $m) !== 1) { + continue; + } + if (in_array($m[1], SW_SETUP_KEYS, true)) { + $out[$m[1]] = str_replace(['\\"', '\\\\'], ['"', '\\'], $m[2]); + } + } + return $out; +} + +function setup_save(array $values): bool +{ + $lines = []; + foreach (SW_SETUP_KEYS as $key) { + if (!isset($values[$key])) { + continue; + } + $value = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $values[$key]); + $lines[] = $key . ': "' . $value . '"'; + } + $ok = @file_put_contents(setup_file(), implode("\n", $lines) . "\n", LOCK_EX) !== false; + if ($ok) { + @chmod(setup_file(), 0600); + } + return $ok; +} + +function setup_apply(array $values): void +{ + foreach ($values as $key => $value) { + if ($key === 'nect_start') { + $providers = SW::$cfg['eid_providers']; + $providers['nect']['start'] = (string) $value; + SW::$cfg['eid_providers'] = $providers; + continue; + } + if (in_array($key, SW_SETUP_KEYS, true)) { + SW::$cfg[$key] = $value; + } + } +} + +function setup_read_post(): array +{ + return [ + 'eid_mode' => post_str('eid_mode', 10) === 'eid' ? 'eid' : 'demo', + 'eid_server_url' => post_str('eid_server_url', 300), + 'eid_server_cert' => post_str('eid_server_cert', 300), + 'eid_server_key' => post_str('eid_server_key', 300), + 'eid_client_url' => post_str('eid_client_url', 200), + 'authorized_keys_url' => post_str('authorized_keys_url', 300), + 'nect_start' => post_str('nect_start', 300), + ]; +} + +function setup_validate(array $in): array +{ + $errors = []; + if ($in['eid_client_url'] === '') { + $in['eid_client_url'] = (string) SW_CONFIG['eid_client_url']; + } + if (preg_match('#^https?://[^\s"\']+$#', $in['eid_client_url']) !== 1) { + $errors[] = 'setup.err_client'; + } + foreach (['eid_server_url', 'authorized_keys_url', 'nect_start'] as $key) { + if ($in[$key] !== '' && preg_match('#^https://[^\s"\']+$#', $in[$key]) !== 1) { + $errors[] = 'setup.err_https'; + } + } + foreach (['eid_server_cert', 'eid_server_key'] as $key) { + if ($in[$key] !== '' && !is_readable($in[$key])) { + $errors[] = 'setup.err_file'; + } + } + if ($in['eid_mode'] === 'eid' && $in['eid_server_url'] === '') { + $errors[] = 'setup.err_server_missing'; + } + if ($in['eid_mode'] === 'demo' && $in['authorized_keys_url'] === '' && authorized_count_stable() === 0) { + $errors[] = 'setup.err_list_empty'; + } + return [array_values(array_unique($errors)), $in]; +} + +function setup_probe(array $cfg): bool +{ + return eid_server_useid($cfg) !== null; +} + +function setup_probe_list(string $url): ?int +{ + if ($url === '') { + return authorized_count_stable(); + } + if (preg_match('#^https://#', $url) !== 1) { + return null; + } + $ctx = stream_context_create([ + 'http' => ['timeout' => 8], + 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], + ]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + return null; + } + $found = preg_match_all('/[0-9a-fA-F]{64,128}/', $body, $mm); + return $found === false ? null : $found; +} + +function setup_check_stamp(array $values): string +{ + return sw_hmac('setup-check|' . implode('|', [ + (string) ($values['eid_mode'] ?? ''), + (string) ($values['eid_server_url'] ?? ''), + (string) ($values['eid_server_cert'] ?? ''), + (string) ($values['eid_server_key'] ?? ''), + (string) ($values['authorized_keys_url'] ?? ''), + ])); +} + +function setup_checked(array $values): bool +{ + return hash_equals((string) ($_SESSION['setup_check'] ?? ''), setup_check_stamp($values)); +} + +function setup_ready(): array +{ + $htaccess = is_file(__DIR__ . '/.htaccess'); + return [ + ['setup.check_data', is_writable(SW::$dataDir)], + ['setup.check_https', sw_is_https()], + ['setup.check_sodium', card_supports_sodium()], + ['setup.check_htaccess', $htaccess], + ]; +} + +function sw_hmac(string $value): string +{ + return hash_hmac('sha256', $value, SW::$pepper); +} + +function test_mode(): bool +{ + if (SW::$testMode === null) { + SW::$testMode = SW::$db !== null + && (string) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'test_mode'") ?? '1') === '1'; + } + return SW::$testMode; +} + +function test_mode_end(): void +{ + SW::$db->tx(function (): void { + SW::$db->run('DELETE FROM report_jurors'); + SW::$db->run('DELETE FROM reports'); + SW::$db->run('DELETE FROM votes'); + SW::$db->run('DELETE FROM favorites'); + SW::$db->run('DELETE FROM topics'); + SW::$db->run('DELETE FROM users WHERE is_system = 0'); + SW::$db->run('DELETE FROM rate_limits'); + SW::$db->run('DELETE FROM eid_flows'); + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('test_mode', '0') + ON CONFLICT(k) DO UPDATE SET v = '0'" + ); + }); + SW::$testMode = false; + + authorized_remove(test_keys_load()); + @unlink(test_keys_file()); + log_line('SECURITY', 'test_mode_ended', []); +} + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function t(string $key, array $repl = []): string +{ + $text = SW::$tActive[$key] ?? SW_DE[$key] ?? $key; + foreach ($repl as $name => $value) { + $text = str_replace('{' . $name . '}', (string) $value, $text); + } + return $text; +} + +function num(int $n): string +{ + return SW::$lang === 'de' ? number_format($n, 0, ',', '.') : number_format($n); +} + +function base_path(): string +{ + return SW::$base; +} + +function url(string $path): string +{ + $full = base_path() . $path; + return $full === '' ? '/' : $full; +} + +function redirect(string $path): void +{ + if ($path === '' || $path[0] !== '/' || strpos($path, '//') === 0) { + $path = '/'; + } + header('Location: ' . url($path), true, 303); + exit; +} + +function post_str(string $key, int $maxLen, bool $multiline = false): string +{ + $value = $_POST[$key] ?? ''; + if (!is_string($value) || !mb_check_encoding($value, 'UTF-8')) { + return ''; + } + $pattern = $multiline ? '/[^\P{C}\n\t]/u' : '/\p{C}/u'; + $value = trim((string) preg_replace($pattern, '', $value)); + return mb_strlen($value) > $maxLen ? mb_substr($value, 0, $maxLen) : $value; +} + +function post_int(string $key): ?int +{ + $value = $_POST[$key] ?? null; + return (is_string($value) && preg_match('/^\d{1,10}$/', $value) === 1) ? (int) $value : null; +} + +function post_str_list(string $key, array $allowed): array +{ + $values = $_POST[$key] ?? []; + if (!is_array($values)) { + return []; + } + $out = []; + foreach ($values as $value) { + if (is_string($value) && in_array($value, $allowed, true)) { + $out[] = $value; + } + } + return array_values(array_unique($out)); +} + +function query_str(string $key, int $maxLen = 120): string +{ + $value = $_GET[$key] ?? ''; + if (!is_string($value) || !mb_check_encoding($value, 'UTF-8')) { + return ''; + } + $value = trim((string) preg_replace('/\p{C}/u', '', $value)); + return mb_strlen($value) > $maxLen ? mb_substr($value, 0, $maxLen) : $value; +} + +function query_int(string $key, int $min, int $max, int $default): int +{ + $value = $_GET[$key] ?? null; + if (is_string($value) && preg_match('/^\d{1,9}$/', $value) === 1) { + return max($min, min($max, (int) $value)); + } + return $default; +} + +function log_line(string $level, string $event, array $context = []): void +{ + $line = sprintf( + "%s %s %s %s\n", + Clock::nowStr(), + $level, + $event, + $context === [] ? '' : json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + @file_put_contents(SW::$dataDir . '/app.log', $line, FILE_APPEND | LOCK_EX); +} + +function sw_is_https(): bool +{ + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443 + || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; +} + +function consent_given(): bool +{ + return (string) ($_COOKIE['sw_consent'] ?? '') === '1'; +} + +function consent_set(): void +{ + setcookie('sw_consent', '1', [ + 'expires' => time() + 31536000, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + +function consent_return(): string +{ + $to = query_str('to', 120); + return preg_match('#^/(topic/\d{1,10}|imprint|privacy)?$#', $to) === 1 && $to !== '' ? $to : '/'; +} + +function lang_from_browser(): string +{ + $raw = (string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''); + $first = strtolower(substr(trim(explode(',', $raw)[0]), 0, 2)); + return in_array($first, (array) SW::$cfg['langs'], true) ? $first : (string) SW::$cfg['default_lang']; +} + +function session_boot(): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + if (!consent_given()) { + $GLOBALS['_SESSION'] = []; + return; + } + session_name('sw_session'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + $now = time(); + $idle = isset($_SESSION['last_activity']) + && ($now - (int) $_SESSION['last_activity']) > (int) SW::$cfg['session_idle_minutes'] * 60; + $expired = isset($_SESSION['auth_time']) + && ($now - (int) $_SESSION['auth_time']) > (int) SW::$cfg['session_max_hours'] * 3600; + if (($idle || $expired) && isset($_SESSION['user_id'])) { + unset($_SESSION['user_id'], $_SESSION['auth_time']); + session_regenerate_id(true); + flash('info', 'flash.session_expired'); + } + $_SESSION['last_activity'] = $now; +} + +function flash(string $type, string $key, array $repl = []): void +{ + $_SESSION['flash'][] = ['type' => $type, 'key' => $key, 'repl' => $repl]; +} + +function take_flashes(): array +{ + $flashes = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + return is_array($flashes) ? $flashes : []; +} + +function csrf_field(): string +{ + $token = bin2hex(random_bytes(16)); + $list = isset($_SESSION['ot']) && is_array($_SESSION['ot']) ? $_SESSION['ot'] : []; + $list[$token] = time(); + if (count($list) > 40) { + $list = array_slice($list, -40, null, true); + } + $_SESSION['ot'] = $list; + return ''; +} + +function csrf_ok(): bool +{ + $sent = $_POST['_csrf'] ?? ''; + if (!is_string($sent) || !isset($_SESSION['ot']) || !is_array($_SESSION['ot']) || !isset($_SESSION['ot'][$sent])) { + return false; + } + unset($_SESSION['ot'][$sent]); + return true; +} + +function rate_allow(string $key, int $max, int $windowSeconds): bool +{ + $now = Clock::now()->getTimestamp(); + return SW::$db->tx(function () use ($key, $max, $windowSeconds, $now): bool { + $row = SW::$db->one('SELECT window_start, cnt FROM rate_limits WHERE k = ?', [$key]); + if ($row === null || ($now - (int) $row['window_start']) >= $windowSeconds) { + SW::$db->run( + 'INSERT INTO rate_limits (k, window_start, cnt) VALUES (?, ?, 1) + ON CONFLICT(k) DO UPDATE SET window_start = excluded.window_start, cnt = 1', + [$key, $now] + ); + return true; + } + if ((int) $row['cnt'] >= $max) { + return false; + } + SW::$db->run('UPDATE rate_limits SET cnt = cnt + 1 WHERE k = ?', [$key]); + return true; + }); +} + +function rate_gc(): void +{ + SW::$db->run('DELETE FROM rate_limits WHERE window_start < ?', [Clock::now()->getTimestamp() - 86400]); +} + +function ip_key(): string +{ + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + return substr(sw_hmac('ip|' . Clock::localDate() . '|' . $ip), 0, 24); +} + +function card_supports_sodium(): bool +{ + return function_exists('sodium_crypto_sign_keypair'); +} + +function authorized_file(): string +{ + return SW::$dataDir . '/authorized_keys.yaml'; +} + +function authorized_load(): array +{ + $file = authorized_file(); + if (!is_file($file)) { + return []; + } + $set = []; + foreach (preg_split('/\r?\n/', (string) file_get_contents($file)) as $line) { + if (preg_match('/["\x27]?([0-9a-fA-F]{64,128})["\x27]?\s*$/', trim($line), $m) === 1 + && strpos(trim($line), '-') === 0) { + $set[strtolower($m[1])] = true; + } + } + return $set; +} + +function authorized_contains(string $pkHex): bool +{ + return isset(authorized_load()[strtolower($pkHex)]); +} + +function authorized_count(): int +{ + return count(authorized_load()); +} + +function test_keys_file(): string +{ + return SW::$dataDir . '/test_keys.list'; +} + +function test_keys_load(): array +{ + $file = test_keys_file(); + if (!is_file($file)) { + return []; + } + $out = []; + foreach (preg_split('/\r?\n/', (string) file_get_contents($file)) as $line) { + $hex = strtolower(trim($line)); + if (preg_match('/^[0-9a-f]{64,128}$/', $hex) === 1) { + $out[] = $hex; + } + } + return $out; +} + +function test_key_add(string $pkHex): void +{ + @file_put_contents(test_keys_file(), strtolower($pkHex) . "\n", FILE_APPEND | LOCK_EX); + @chmod(test_keys_file(), 0600); +} + +function authorized_count_stable(): int +{ + $set = authorized_load(); + foreach (test_keys_load() as $hex) { + unset($set[$hex]); + } + return count($set); +} + +function authorized_remove(array $pkHexList): int +{ + $set = authorized_load(); + $removed = 0; + foreach ($pkHexList as $hex) { + $hex = strtolower(trim($hex)); + if (isset($set[$hex])) { + unset($set[$hex]); + $removed++; + } + } + if ($removed > 0) { + authorized_write($set, 'cleanup'); + } + return $removed; +} + +function authorized_add(array $pkHexList, string $source): int +{ + $set = authorized_load(); + $added = 0; + foreach ($pkHexList as $hex) { + $hex = strtolower(trim($hex)); + if (preg_match('/^[0-9a-f]{64,128}$/', $hex) === 1 && !isset($set[$hex])) { + $set[$hex] = true; + $added++; + } + } + authorized_write($set, $source); + return $added; +} + +function authorized_write(array $set, string $source): void +{ + $y = "buergerabstimmung_authorized_keys:\n"; + $y .= " hinweis: \"Oeffentliche Schluessel autorisierter Ausweise. Nur diese koennen sich anmelden.\"\n"; + $y .= " aktualisiert: \"" . Clock::nowStr() . "\"\n"; + $y .= " quelle: \"" . str_replace('"', '', $source) . "\"\n"; + $y .= " schluessel:\n"; + if ($set === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach (array_keys($set) as $hex) { + $y .= " - \"" . $hex . "\"\n"; + } + @file_put_contents(authorized_file(), $y, LOCK_EX); + @chmod(authorized_file(), 0640); +} + +function card_load(): ?array +{ + $raw = $_SESSION['card'] ?? ''; + if (!is_string($raw) || $raw === '') { + return null; + } + $secret = base64_decode($raw, true); + if ($secret === false) { + return null; + } + if (card_supports_sodium()) { + if (strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + return null; + } + return ['secret' => $secret, 'pk' => sodium_crypto_sign_publickey_from_secretkey($secret)]; + } + if (strlen($secret) !== 32) { + return null; + } + return ['secret' => $secret, 'pk' => hash('sha256', 'pk|' . $secret, true)]; +} + +function card_create(): array +{ + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($pair); + $pk = sodium_crypto_sign_publickey($pair); + } else { + $secret = random_bytes(32); + $pk = hash('sha256', 'pk|' . $secret, true); + } + $_SESSION['card'] = base64_encode($secret); + return ['secret' => $secret, 'pk' => $pk]; +} + +function card_forget(): void +{ + unset($_SESSION['card']); +} + +function card_identity(array $card): string +{ + return bin2hex($card['pk']); +} + +const SW_SLOT_SECONDS = 300; +const SW_AUTH_SLOTS = 2; + +function time_slot(): int +{ + return intdiv(Clock::now()->getTimestamp(), SW_SLOT_SECONDS); +} + +function card_seal(array $card, string $action): string +{ + $payload = json_encode(['a' => $action, 'slot' => time_slot(), 'n' => bin2hex(random_bytes(8))]); + if (card_supports_sodium()) { + return sodium_crypto_sign($payload, $card['secret']); + } + + return $payload . '.' . hash('sha256', 'seal|' . $card['pk'] . '|' . $payload); +} + +function card_open(string $pk, string $sealed, string $action): bool +{ + if (card_supports_sodium()) { + $payload = sodium_crypto_sign_open($sealed, $pk); + if ($payload === false) { + return false; + } + } else { + $dot = strrpos($sealed, '.'); + if ($dot === false) { + return false; + } + $payload = substr($sealed, 0, $dot); + $mac = substr($sealed, $dot + 1); + if (!hash_equals(hash('sha256', 'seal|' . $pk . '|' . $payload), $mac)) { + return false; + } + } + $data = json_decode($payload, true); + if (!is_array($data) || ($data['a'] ?? '') !== $action) { + return false; + } + $slot = (int) ($data['slot'] ?? -1); + $current = time_slot(); + return $slot === $current || $slot === $current - 1; +} + +function auth_login(string $pseudonymHash): array +{ + $now = Clock::nowStr(); + $user = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pseudonymHash]); + if ($user === null) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, created_at, last_login_at) VALUES (?, ?, ?, ?)', + [$pseudonymHash, (string) SW::$cfg['default_lang'], $now, $now] + ); + $user = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pseudonymHash]); + } else { + SW::$db->run('UPDATE users SET last_login_at = ? WHERE id = ?', [$now, (int) $user['id']]); + } + session_regenerate_id(true); + $_SESSION['user_id'] = (int) $user['id']; + $_SESSION['auth_time'] = time(); + SW::$user = $user; + return $user; +} + +function auth_logout(): void +{ + unset($_SESSION['user_id'], $_SESSION['auth_time']); + session_regenerate_id(true); + SW::$user = null; +} + +function auth_user(): ?array +{ + if (SW::$user !== null) { + return SW::$user; + } + $id = $_SESSION['user_id'] ?? null; + if (!is_int($id)) { + return null; + } + + $slot = $_SESSION['auth_slot'] ?? null; + if (!test_mode() && is_int($slot) && (time_slot() - $slot) >= SW_AUTH_SLOTS) { + unset($_SESSION['user_id'], $_SESSION['auth_time'], $_SESSION['auth_slot']); + card_forget(); + session_regenerate_id(true); + flash('info', 'flash.auth_expired'); + return null; + } + SW::$user = SW::$db->one('SELECT * FROM users WHERE id = ? AND is_system = 0', [$id]); + return SW::$user; +} + +function require_user(): array +{ + $user = auth_user(); + if ($user === null) { + flash('info', 'flash.login_required'); + redirect('/auth'); + } + return $user; +} + +function card_confirm_ok(array $user, ?array $card, string $action): bool +{ + if (test_mode()) { + return true; + } + if ($card === null) { + return false; + } + if (!hash_equals((string) $user['pseudonym_hash'], card_identity($card))) { + return false; + } + return card_open($card['pk'], card_seal($card, $action), $action); +} + +function require_card(array $user): void +{ + $action = 'confirm:' . SW::$path; + if (!card_confirm_ok($user, card_load(), $action)) { + log_line('SECURITY', 'card_confirm_failed', []); + flash('error', 'flash.card_required'); + redirect('/auth'); + } +} + +function short_id(array $user): string +{ + return strtoupper(substr((string) $user['pseudonym_hash'], 0, 8)); +} + +const SW_TITLE_MIN = 8; +const SW_TITLE_MAX = 120; +const SW_GOAL_MIN = 10; +const SW_GOAL_MAX = 500; +const SW_REASONING_MIN = 10; +const SW_REASONING_MAX = 4000; + +const SW_REGIONS = [ + 'Baden-Württemberg' => ['Alb-Donau-Kreis', 'Baden-Baden (Stadt)', 'Bodenseekreis', 'Enzkreis', 'Freiburg im Breisgau (Stadt)', 'Heidelberg (Stadt)', 'Heilbronn (Stadt)', 'Hohenlohekreis', 'Karlsruhe (Stadt)', 'Landkreis Biberach', 'Landkreis Breisgau-Hochschwarzwald', 'Landkreis Böblingen', 'Landkreis Calw', 'Landkreis Emmendingen', 'Landkreis Esslingen', 'Landkreis Freudenstadt', 'Landkreis Göppingen', 'Landkreis Heidenheim', 'Landkreis Heilbronn', 'Landkreis Karlsruhe', 'Landkreis Konstanz', 'Landkreis Ludwigsburg', 'Landkreis Lörrach', 'Landkreis Rastatt', 'Landkreis Ravensburg', 'Landkreis Reutlingen', 'Landkreis Rottweil', 'Landkreis Schwäbisch Hall', 'Landkreis Sigmaringen', 'Landkreis Tuttlingen', 'Landkreis Tübingen', 'Landkreis Waldshut', 'Main-Tauber-Kreis', 'Mannheim (Stadt)', 'Neckar-Odenwald-Kreis', 'Ortenaukreis', 'Ostalbkreis', 'Pforzheim (Stadt)', 'Rems-Murr-Kreis', 'Rhein-Neckar-Kreis', 'Schwarzwald-Baar-Kreis', 'Stuttgart (Stadt)', 'Ulm (Stadt)', 'Zollernalbkreis'], + 'Bayern' => ['Amberg (Stadt)', 'Ansbach (Stadt)', 'Aschaffenburg (Stadt)', 'Augsburg (Stadt)', 'Bamberg (Stadt)', 'Bayreuth (Stadt)', 'Coburg (Stadt)', 'Erlangen (Stadt)', 'Fürth (Stadt)', 'Hof (Stadt)', 'Ingolstadt (Stadt)', 'Kaufbeuren (Stadt)', 'Kempten (Allgäu) (Stadt)', 'Landkreis Aichach-Friedberg', 'Landkreis Altötting', 'Landkreis Amberg-Sulzbach', 'Landkreis Ansbach', 'Landkreis Aschaffenburg', 'Landkreis Augsburg', 'Landkreis Bad Kissingen', 'Landkreis Bad Tölz-Wolfratshausen', 'Landkreis Bamberg', 'Landkreis Bayreuth', 'Landkreis Berchtesgadener Land', 'Landkreis Cham', 'Landkreis Coburg', 'Landkreis Dachau', 'Landkreis Deggendorf', 'Landkreis Dillingen a.d.Donau', 'Landkreis Dingolfing-Landau', 'Landkreis Donau-Ries', 'Landkreis Ebersberg', 'Landkreis Eichstätt', 'Landkreis Erding', 'Landkreis Erlangen-Höchstadt', 'Landkreis Forchheim', 'Landkreis Freising', 'Landkreis Freyung-Grafenau', 'Landkreis Fürstenfeldbruck', 'Landkreis Fürth', 'Landkreis Garmisch-Partenkirchen', 'Landkreis Günzburg', 'Landkreis Haßberge', 'Landkreis Hof', 'Landkreis Kelheim', 'Landkreis Kitzingen', 'Landkreis Kronach', 'Landkreis Kulmbach', 'Landkreis Landsberg am Lech', 'Landkreis Landshut', 'Landkreis Lichtenfels', 'Landkreis Lindau (Bodensee)', 'Landkreis Main-Spessart', 'Landkreis Miesbach', 'Landkreis Miltenberg', 'Landkreis Mühldorf a.Inn', 'Landkreis München', 'Landkreis Neu-Ulm', 'Landkreis Neuburg-Schrobenhausen', 'Landkreis Neumarkt i.d.OPf.', 'Landkreis Neustadt a.d.Aisch-Bad Windsheim', 'Landkreis Neustadt a.d.Waldnaab', 'Landkreis Nürnberger Land', 'Landkreis Oberallgäu', 'Landkreis Ostallgäu', 'Landkreis Passau', 'Landkreis Pfaffenhofen a.d.Ilm', 'Landkreis Regen', 'Landkreis Regensburg', 'Landkreis Rhön-Grabfeld', 'Landkreis Rosenheim', 'Landkreis Roth', 'Landkreis Rottal-Inn', 'Landkreis Schwandorf', 'Landkreis Schweinfurt', 'Landkreis Starnberg', 'Landkreis Straubing-Bogen', 'Landkreis Tirschenreuth', 'Landkreis Traunstein', 'Landkreis Unterallgäu', 'Landkreis Weilheim-Schongau', 'Landkreis Weißenburg-Gunzenhausen', 'Landkreis Wunsiedel i.Fichtelgebirge', 'Landkreis Würzburg', 'Landshut (Stadt)', 'Memmingen (Stadt)', 'München (Stadt)', 'Nürnberg (Stadt)', 'Passau (Stadt)', 'Regensburg (Stadt)', 'Rosenheim (Stadt)', 'Schwabach (Stadt)', 'Schweinfurt (Stadt)', 'Straubing (Stadt)', 'Weiden i.d.OPf. (Stadt)', 'Würzburg (Stadt)'], + 'Berlin' => [], + 'Brandenburg' => ['Brandenburg an der Havel (Stadt)', 'Cottbus (Stadt)', 'Frankfurt (Oder) (Stadt)', 'Landkreis Barnim', 'Landkreis Dahme-Spreewald', 'Landkreis Elbe-Elster', 'Landkreis Havelland', 'Landkreis Märkisch-Oderland', 'Landkreis Oberhavel', 'Landkreis Oberspreewald-Lausitz', 'Landkreis Oder-Spree', 'Landkreis Ostprignitz-Ruppin', 'Landkreis Potsdam-Mittelmark', 'Landkreis Prignitz', 'Landkreis Spree-Neiße', 'Landkreis Teltow-Fläming', 'Landkreis Uckermark', 'Potsdam (Stadt)'], + 'Bremen' => ['Bremen (Stadt)', 'Bremerhaven (Stadt)'], + 'Hamburg' => [], + 'Hessen' => ['Darmstadt (Stadt)', 'Frankfurt am Main (Stadt)', 'Hochtaunuskreis', 'Kassel (Stadt)', 'Lahn-Dill-Kreis', 'Landkreis Bergstraße', 'Landkreis Darmstadt-Dieburg', 'Landkreis Fulda', 'Landkreis Gießen', 'Landkreis Groß-Gerau', 'Landkreis Hersfeld-Rotenburg', 'Landkreis Kassel', 'Landkreis Limburg-Weilburg', 'Landkreis Marburg-Biedenkopf', 'Landkreis Offenbach', 'Landkreis Waldeck-Frankenberg', 'Main-Kinzig-Kreis', 'Main-Taunus-Kreis', 'Odenwaldkreis', 'Offenbach am Main (Stadt)', 'Rheingau-Taunus-Kreis', 'Schwalm-Eder-Kreis', 'Vogelsbergkreis', 'Werra-Meißner-Kreis', 'Wetteraukreis', 'Wiesbaden (Stadt)'], + 'Mecklenburg-Vorpommern' => ['Landkreis Ludwigslust-Parchim', 'Landkreis Mecklenburgische Seenplatte', 'Landkreis Nordwestmecklenburg', 'Landkreis Rostock', 'Landkreis Vorpommern-Greifswald', 'Landkreis Vorpommern-Rügen', 'Rostock (Stadt)', 'Schwerin (Stadt)'], + 'Niedersachsen' => ['Braunschweig (Stadt)', 'Delmenhorst (Stadt)', 'Emden (Stadt)', 'Heidekreis', 'Landkreis Ammerland', 'Landkreis Aurich', 'Landkreis Celle', 'Landkreis Cloppenburg', 'Landkreis Cuxhaven', 'Landkreis Diepholz', 'Landkreis Emsland', 'Landkreis Friesland', 'Landkreis Gifhorn', 'Landkreis Goslar', 'Landkreis Grafschaft Bentheim', 'Landkreis Göttingen', 'Landkreis Hameln-Pyrmont', 'Landkreis Harburg', 'Landkreis Helmstedt', 'Landkreis Hildesheim', 'Landkreis Holzminden', 'Landkreis Leer', 'Landkreis Lüchow-Dannenberg', 'Landkreis Lüneburg', 'Landkreis Nienburg/Weser', 'Landkreis Northeim', 'Landkreis Oldenburg', 'Landkreis Osnabrück', 'Landkreis Osterholz', 'Landkreis Peine', 'Landkreis Rotenburg (Wümme)', 'Landkreis Schaumburg', 'Landkreis Stade', 'Landkreis Uelzen', 'Landkreis Vechta', 'Landkreis Verden', 'Landkreis Wesermarsch', 'Landkreis Wittmund', 'Landkreis Wolfenbüttel', 'Oldenburg (Stadt)', 'Osnabrück (Stadt)', 'Region Hannover', 'Salzgitter (Stadt)', 'Wilhelmshaven (Stadt)', 'Wolfsburg (Stadt)'], + 'Nordrhein-Westfalen' => ['Bielefeld (Stadt)', 'Bochum (Stadt)', 'Bonn (Stadt)', 'Bottrop (Stadt)', 'Dortmund (Stadt)', 'Duisburg (Stadt)', 'Düsseldorf (Stadt)', 'Ennepe-Ruhr-Kreis', 'Essen (Stadt)', 'Gelsenkirchen (Stadt)', 'Hagen (Stadt)', 'Hamm (Stadt)', 'Herne (Stadt)', 'Hochsauerlandkreis', 'Krefeld (Stadt)', 'Köln (Stadt)', 'Landkreis Borken', 'Landkreis Coesfeld', 'Landkreis Düren', 'Landkreis Euskirchen', 'Landkreis Gütersloh', 'Landkreis Heinsberg', 'Landkreis Herford', 'Landkreis Höxter', 'Landkreis Kleve', 'Landkreis Lippe', 'Landkreis Mettmann', 'Landkreis Minden-Lübbecke', 'Landkreis Olpe', 'Landkreis Paderborn', 'Landkreis Recklinghausen', 'Landkreis Siegen-Wittgenstein', 'Landkreis Soest', 'Landkreis Steinfurt', 'Landkreis Städteregion Aachen', 'Landkreis Unna', 'Landkreis Viersen', 'Landkreis Warendorf', 'Landkreis Wesel', 'Leverkusen (Stadt)', 'Märkischer Kreis', 'Mönchengladbach (Stadt)', 'Mülheim an der Ruhr (Stadt)', 'Münster (Stadt)', 'Oberbergischer Kreis', 'Oberhausen (Stadt)', 'Remscheid (Stadt)', 'Rhein-Erft-Kreis', 'Rhein-Kreis Neuss', 'Rhein-Sieg-Kreis', 'Rheinisch-Bergischer Kreis', 'Solingen (Stadt)', 'Wuppertal (Stadt)'], + 'Rheinland-Pfalz' => ['Donnersbergkreis', 'Eifelkreis Bitburg-Prüm', 'Frankenthal (Pfalz) (Stadt)', 'Kaiserslautern (Stadt)', 'Koblenz (Stadt)', 'Landau in der Pfalz (Stadt)', 'Landkreis Ahrweiler', 'Landkreis Altenkirchen (Westerwald)', 'Landkreis Alzey-Worms', 'Landkreis Bad Dürkheim', 'Landkreis Bad Kreuznach', 'Landkreis Bernkastel-Wittlich', 'Landkreis Birkenfeld', 'Landkreis Cochem-Zell', 'Landkreis Germersheim', 'Landkreis Kaiserslautern', 'Landkreis Kusel', 'Landkreis Mainz-Bingen', 'Landkreis Mayen-Koblenz', 'Landkreis Neuwied', 'Landkreis Südliche Weinstraße', 'Landkreis Südwestpfalz', 'Landkreis Trier-Saarburg', 'Landkreis Vulkaneifel', 'Ludwigshafen am Rhein (Stadt)', 'Mainz (Stadt)', 'Neustadt an der Weinstraße (Stadt)', 'Pirmasens (Stadt)', 'Rhein-Hunsrück-Kreis', 'Rhein-Lahn-Kreis', 'Rhein-Pfalz-Kreis', 'Speyer (Stadt)', 'Trier (Stadt)', 'Westerwaldkreis', 'Worms (Stadt)', 'Zweibrücken (Stadt)'], + 'Saarland' => ['Landkreis Merzig-Wadern', 'Landkreis Neunkirchen', 'Landkreis Saarlouis', 'Landkreis St. Wendel', 'Regionalverband Saarbrücken', 'Saarpfalz-Kreis'], + 'Sachsen' => ['Chemnitz (Stadt)', 'Dresden (Stadt)', 'Erzgebirgskreis', 'Landkreis Bautzen', 'Landkreis Görlitz', 'Landkreis Leipzig', 'Landkreis Meißen', 'Landkreis Mittelsachsen', 'Landkreis Nordsachsen', 'Landkreis Sächsische Schweiz-Osterzgebirge', 'Landkreis Zwickau', 'Leipzig (Stadt)', 'Vogtlandkreis'], + 'Sachsen-Anhalt' => ['Altmarkkreis Salzwedel', 'Burgenlandkreis', 'Dessau-Roßlau (Stadt)', 'Halle (Saale) (Stadt)', 'Landkreis Anhalt-Bitterfeld', 'Landkreis Börde', 'Landkreis Harz', 'Landkreis Jerichower Land', 'Landkreis Mansfeld-Südharz', 'Landkreis Stendal', 'Landkreis Wittenberg', 'Magdeburg (Stadt)', 'Saalekreis', 'Salzlandkreis'], + 'Schleswig-Holstein' => ['Flensburg (Stadt)', 'Kiel (Stadt)', 'Landkreis Dithmarschen', 'Landkreis Herzogtum Lauenburg', 'Landkreis Nordfriesland', 'Landkreis Ostholstein', 'Landkreis Pinneberg', 'Landkreis Plön', 'Landkreis Rendsburg-Eckernförde', 'Landkreis Schleswig-Flensburg', 'Landkreis Segeberg', 'Landkreis Steinburg', 'Landkreis Stormarn', 'Lübeck (Stadt)', 'Neumünster (Stadt)'], + 'Thüringen' => ['Erfurt (Stadt)', 'Gera (Stadt)', 'Ilm-Kreis', 'Jena (Stadt)', 'Kyffhäuserkreis', 'Landkreis Altenburger Land', 'Landkreis Eichsfeld', 'Landkreis Gotha', 'Landkreis Greiz', 'Landkreis Hildburghausen', 'Landkreis Nordhausen', 'Landkreis Saalfeld-Rudolstadt', 'Landkreis Schmalkalden-Meiningen', 'Landkreis Sonneberg', 'Landkreis Sömmerda', 'Landkreis Weimarer Land', 'Saale-Holzland-Kreis', 'Saale-Orla-Kreis', 'Suhl (Stadt)', 'Unstrut-Hainich-Kreis', 'Wartburgkreis', 'Weimar (Stadt)'], +]; + +function scope_decode(string $value): ?array +{ + if ($value === 'de') { + return ['bund', null]; + } + if (strpos($value, 'bl:') === 0) { + $land = substr($value, 3); + return isset(SW_REGIONS[$land]) ? ['bundesland', $land] : null; + } + if (strpos($value, 'kr:') === 0) { + $parts = explode(':', substr($value, 3), 2); + if (count($parts) === 2 && isset(SW_REGIONS[$parts[0]]) + && in_array($parts[1], SW_REGIONS[$parts[0]], true)) { + return ['landkreis', $parts[1]]; + } + return null; + } + return null; +} + +function fav_to_gebiet(string $ref): ?string +{ + if ($ref === 'bund') { + return 'de'; + } + $parts = explode(':', $ref, 2); + if (count($parts) !== 2) { + return null; + } + if ($parts[0] === 'bundesland' && isset(SW_REGIONS[$parts[1]])) { + return 'bl:' . $parts[1]; + } + if ($parts[0] === 'landkreis') { + foreach (SW_REGIONS as $land => $kreise) { + if (in_array($parts[1], $kreise, true)) { + return 'kr:' . $land . ':' . $parts[1]; + } + } + } + return null; +} + +function scope_picker(string $name, string $selected, bool $withAll): string +{ + $html = ''; +} + +const SW_CATEGORIES = [ + ['umwelt-klima', 'Umwelt & Klima', 'Environment & Climate'], + ['energie', 'Energie', 'Energy'], + ['wirtschaft', 'Wirtschaft & Mittelstand', 'Economy & Business'], + ['arbeit-soziales', 'Arbeit & Soziales', 'Labour & Social Affairs'], + ['rente', 'Rente & Alterssicherung', 'Pensions'], + ['gesundheit-pflege', 'Gesundheit & Pflege', 'Health & Care'], + ['bildung-forschung', 'Bildung & Forschung', 'Education & Research'], + ['familie-jugend', 'Familie & Jugend', 'Family & Youth'], + ['migration-integration', 'Migration & Integration', 'Migration & Integration'], + ['innere-sicherheit', 'Innere Sicherheit', 'Domestic Security'], + ['justiz-buergerrechte', 'Justiz & Bürgerrechte', 'Justice & Civil Rights'], + ['digitales', 'Digitales & Verwaltung', 'Digital Affairs & Administration'], + ['verkehr', 'Verkehr & Infrastruktur', 'Transport & Infrastructure'], + ['wohnen', 'Wohnen & Mieten', 'Housing & Rents'], + ['landwirtschaft', 'Landwirtschaft & Ernährung', 'Agriculture & Food'], + ['finanzen-steuern', 'Finanzen & Steuern', 'Finance & Taxes'], + ['europa-aussen', 'Europa & Außenpolitik', 'Europe & Foreign Policy'], + ['verteidigung', 'Verteidigung', 'Defence'], + ['kultur-medien-sport', 'Kultur, Medien & Sport', 'Culture, Media & Sports'], + ['verbraucherschutz', 'Verbraucherschutz', 'Consumer Protection'], + ['kommunales', 'Kommunales & Ehrenamt', 'Local Affairs & Volunteering'], + ['demokratie', 'Demokratie & Beteiligung', 'Democracy & Participation'], +]; + +function sw_seed_categories(): void +{ + if ((int) SW::$db->val('SELECT COUNT(*) FROM categories') > 0) { + return; + } + SW::$db->tx(function (): void { + foreach (SW_CATEGORIES as $i => $row) { + SW::$db->run( + 'INSERT INTO categories (slug, name_de, name_en, sort_order) VALUES (?, ?, ?, ?)', + [$row[0], $row[1], $row[2], $i] + ); + } + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, is_system, created_at) VALUES (?, ?, 1, ?)', + ['system', 'de', Clock::nowStr()] + ); + }); +} + +function categories(): array +{ + return SW::$db->all('SELECT * FROM categories ORDER BY sort_order, id'); +} + +function cat_name(array $row): string +{ + return SW::$lang === 'de' ? (string) $row['name_de'] : (string) $row['name_en']; +} + +function topic_has_posted_today(int $userId): bool +{ + return null !== SW::$db->one( + 'SELECT 1 FROM topics WHERE author_id = ? AND created_date = ?', + [$userId, Clock::localDate()] + ); +} + +function topic_create(int $userId, string $title, string $goal, string $reasoning, int $categoryId, string $scopeLevel, ?string $scopeName, string $endMode, ?string $endDate, ?int $endTarget): int +{ + if (topic_has_posted_today($userId)) { + throw new DomainException('flash.topic_daily_limit'); + } + try { + SW::$db->run( + 'INSERT INTO topics (author_id, title, goal, reasoning, category_id, + scope_level, scope_name, end_mode, end_date, end_target, + created_at, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [$userId, $title, $goal, $reasoning, $categoryId, + $scopeLevel, $scopeName, $endMode, $endDate, $endTarget, + Clock::nowStr(), Clock::localDate()] + ); + } catch (PDOException $e) { + throw new DomainException('flash.topic_daily_limit'); + } + return SW::$db->lastId(); +} + +function topic_update(int $topicId, int $userId, string $title, string $goal, string $reasoning, int $categoryId, string $scopeLevel, ?string $scopeName, string $endMode, ?string $endDate, ?int $endTarget): void +{ + $topic = SW::$db->one('SELECT * FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || (int) $topic['author_id'] !== $userId + || in_array((string) $topic['status'], ['removed', 'archived'], true)) { + throw new DomainException('flash.not_author'); + } + SW::$db->run( + 'UPDATE topics SET title = ?, goal = ?, reasoning = ?, category_id = ?, + scope_level = ?, scope_name = ?, end_mode = ?, end_date = ?, end_target = ? + WHERE id = ?', + [$title, $goal, $reasoning, $categoryId, $scopeLevel, $scopeName, + $endMode, $endDate, $endTarget, $topicId] + ); +} + +function topic_archive(int $topicId, int $userId): void +{ + $topic = SW::$db->one('SELECT author_id, status FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || (int) $topic['author_id'] !== $userId + || in_array((string) $topic['status'], ['removed', 'archived'], true)) { + throw new DomainException('flash.not_author'); + } + if (topic_has_votes($topicId)) { + throw new DomainException('flash.topic_locked'); + } + SW::$db->run("UPDATE topics SET status = 'archived' WHERE id = ?", [$topicId]); + log_line('INFO', 'topic_archived', ['topic' => $topicId]); +} + +function topic_has_votes(int $topicId): bool +{ + return (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ?', [$topicId]) > 0; +} + +function topic_title_words(string $title): array +{ + $clean = preg_replace('/[^\p{L}\p{N}]+/u', ' ', mb_strtolower($title)); + $words = []; + foreach (preg_split('/\s+/', (string) $clean) as $word) { + if (mb_strlen($word) >= 4) { + $words[$word] = true; + } + } + return array_slice(array_keys($words), 0, 8); +} + +function topics_similar(string $title, ?int $excludeId = null, int $limit = 5): array +{ + $words = topic_title_words($title); + if ($words === []) { + return []; + } + $where = []; + $args = []; + foreach ($words as $word) { + $where[] = 'lower(t.title) LIKE ?'; + $args[] = '%' . $word . '%'; + } + $sql = 'SELECT t.id, t.title FROM topics t WHERE t.status IN (\'active\', \'closed\') AND (' . implode(' OR ', $where) . ')'; + if ($excludeId !== null) { + $sql .= ' AND t.id != ?'; + $args[] = $excludeId; + } + $sql .= ' ORDER BY t.created_at DESC LIMIT 60'; + $scored = []; + foreach (SW::$db->all($sql, $args) as $row) { + $other = topic_title_words((string) $row['title']); + $shared = count(array_intersect($words, $other)); + if ($shared === 0) { + continue; + } + $row['score'] = $shared / max(1, min(count($words), count($other))); + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + return $b['score'] <=> $a['score']; + }); + return array_slice(array_filter($scored, static function (array $r): bool { + return $r['score'] >= 0.5; + }), 0, $limit); +} + +function parse_topic_end(): ?array +{ + $useDate = isset($_POST['end_by_date']); + $useTarget = isset($_POST['end_by_target']); + if (!$useDate && !$useTarget) { + return null; + } + $date = null; + $target = null; + + if ($useDate) { + $raw = post_str('end_date', 10); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) !== 1) { + return null; + } + $max = substr(Clock::addDaysStr(Clock::nowStr(), 365), 0, 10); + if ($raw <= Clock::localDate() || $raw > $max) { + return null; + } + $date = $raw; + } + if ($useTarget) { + $value = post_int('end_value'); + $unit = post_str('end_unit', 10); + if ($value === null || $value < 1 || !in_array($unit, ['count', 'percent'], true)) { + return null; + } + if ($unit === 'percent') { + if ($value > 100) { + return null; + } + $users = (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'); + $value = (int) ceil($users * $value / 100); + } + if ($value > 100000000) { + return null; + } + $target = max(10, $value); + } + $mode = $date !== null && $target !== null ? 'both' : ($date !== null ? 'date' : 'count'); + return [$mode, $date, $target]; +} + +const SW_TOPIC_SELECT = " + SELECT t.*, c.slug AS category_slug, c.name_de, c.name_en, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against + FROM topics t + JOIN categories c ON c.id = t.category_id"; + +function topics_list(array $filters, int $page, int $perPage, ?int $userId): array +{ + $where = ["t.status IN ('active','closed')"]; + $params = []; + if (!empty($filters['category'])) { + $where[] = 'c.slug = :cat'; + $params[':cat'] = $filters['category']; + } + if (!empty($filters['level'])) { + $where[] = 't.scope_level = :level'; + $params[':level'] = $filters['level']; + } + if (!empty($filters['scope'])) { + $where[] = 't.scope_name = :scope'; + $params[':scope'] = $filters['scope']; + } + if (!empty($filters['q'])) { + $where[] = "t.title LIKE :q ESCAPE '\\'"; + $params[':q'] = '%' . addcslashes($filters['q'], '%_\\') . '%'; + } + $whereSql = ' WHERE ' . implode(' AND ', $where); + $total = (int) SW::$db->val( + 'SELECT COUNT(*) FROM topics t JOIN categories c ON c.id = t.category_id' . $whereSql, + $params + ); + + $netExpr = "((SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for')" + . " - (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against'))"; + $sortMode = $filters['sort'] ?? 'net'; + if ($sortMode === 'new') { + $order = ' ORDER BY t.created_at DESC'; + } elseif ($sortMode === 'top') { + $order = ' ORDER BY (votes_for + votes_against) DESC, t.created_at DESC'; + } else { + $order = ' ORDER BY ' . $netExpr . ' DESC, t.created_at DESC'; + } + $cols = "t.*, c.slug AS category_slug, c.name_de, c.name_en, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against"; + $select = 'SELECT ' . $cols . ' FROM topics t JOIN categories c ON c.id = t.category_id'; + $params[':limit'] = $perPage; + $params[':offset'] = max(0, ($page - 1) * $perPage); + $rows = SW::$db->all($select . $whereSql . $order . ' LIMIT :limit OFFSET :offset', $params); + if ($userId !== null) { + $pk = user_pk($userId); + foreach ($rows as $i => $row) { + $tag = vote_tag((int) $row['id'], $pk); + $rows[$i]['my_choice'] = SW::$db->val('SELECT choice FROM votes WHERE topic_id = ? AND voter_tag = ?', [(int) $row['id'], $tag]); + } + } + return ['rows' => $rows, 'total' => $total]; +} + +function topic_find(int $id): ?array +{ + return SW::$db->one(SW_TOPIC_SELECT . ' WHERE t.id = ?', [$id]); +} + +function topic_user_vote(int $topicId, int $userId): ?string +{ + $row = topic_user_vote_row($topicId, $userId); + return $row === null ? null : (string) $row['choice']; +} + +function topic_user_vote_row(int $topicId, int $userId): ?array +{ + $tag = vote_tag($topicId, user_pk($userId)); + $row = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + if ($row === null) { + return null; + } + $row['locked'] = Clock::nowStr() >= Clock::addHoursStr((string) $row['created_at'], SW_VOTE_CHANGE_HOURS); + return $row; +} + +function topics_by_author(int $userId): array +{ + return SW::$db->all(SW_TOPIC_SELECT . ' WHERE t.author_id = ? ORDER BY t.created_at DESC', [$userId]); +} + +function topics_voted_by(int $userId): array +{ + $pk = user_pk($userId); + $out = []; + foreach (SW::$db->all('SELECT id, title, status FROM topics ORDER BY id DESC') as $topic) { + $tag = vote_tag((int) $topic['id'], $pk); + $vote = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [(int) $topic['id'], $tag]); + if ($vote !== null) { + $topic['my_choice'] = (string) $vote['choice']; + $topic['voted_at'] = (string) $vote['created_at']; + $topic['locked'] = Clock::nowStr() >= Clock::addHoursStr((string) $vote['created_at'], SW_VOTE_CHANGE_HOURS); + $out[] = $topic; + } + } + return $out; +} + +function site_stats(): array +{ + return [ + 'topics' => (int) SW::$db->val("SELECT COUNT(*) FROM topics WHERE status = 'active'"), + 'votes' => (int) SW::$db->val('SELECT COUNT(*) FROM votes'), + 'users' => (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'), + ]; +} + +const SW_VOTE_CHANGE_HOURS = 24; + +function vote_tag(int $topicId, string $pkHex): string +{ + return sw_hmac('vote|' . $topicId . '|' . $pkHex); +} + +function user_pk(int $userId): string +{ + return (string) SW::$db->val('SELECT pseudonym_hash FROM users WHERE id = ?', [$userId]); +} + +function topic_close_if_due(array $topic): string +{ + if ($topic['status'] !== 'active') { + return (string) $topic['status']; + } + + $close = false; + if ($topic['end_date'] !== null && Clock::localDate() > (string) $topic['end_date']) { + $close = true; + } + if ($topic['end_target'] !== null) { + $total = (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ?', [(int) $topic['id']]); + if ($total >= (int) $topic['end_target']) { + $close = true; + } + } + if ($close) { + SW::$db->run("UPDATE topics SET status = 'closed' WHERE id = ? AND status = 'active'", [(int) $topic['id']]); + return 'closed'; + } + return 'active'; +} + +function vote_cast(int $userId, int $topicId, string $choice): void +{ + if (!in_array($choice, ['for', 'against', 'none'], true)) { + throw new DomainException('flash.invalid_input'); + } + $topic = SW::$db->one('SELECT * FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || topic_close_if_due($topic) !== 'active') { + throw new DomainException('flash.topic_not_votable'); + } + $tag = vote_tag($topicId, user_pk($userId)); + $existing = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + if ($existing !== null && Clock::nowStr() >= Clock::addHoursStr((string) $existing['created_at'], SW_VOTE_CHANGE_HOURS)) { + throw new DomainException('flash.vote_locked'); + } + if ($choice === 'none') { + SW::$db->run('DELETE FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + return; + } + $now = Clock::nowStr(); + SW::$db->run( + 'INSERT INTO votes (topic_id, voter_tag, choice, created_at, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(topic_id, voter_tag) DO UPDATE SET choice = excluded.choice, updated_at = excluded.updated_at', + [$topicId, $tag, $choice, $now, $now] + ); + if ($existing === null) { + topic_close_if_due(array_merge($topic, ['status' => 'active'])); + } +} + +function fav_valid(string $kind, string $ref): bool +{ + if ($kind === 'topic') { + return preg_match('/^\d{1,10}$/', $ref) === 1 + && null !== SW::$db->one('SELECT 1 FROM topics WHERE id = ?', [(int) $ref]); + } + if ($kind === 'category') { + return null !== SW::$db->one('SELECT 1 FROM categories WHERE slug = ?', [$ref]); + } + if ($kind === 'scope') { + if ($ref === 'bund') { + return true; + } + $parts = explode(':', $ref, 2); + if (count($parts) !== 2) { + return false; + } + if ($parts[0] === 'bundesland') { + return isset(SW_REGIONS[$parts[1]]); + } + if ($parts[0] === 'landkreis') { + foreach (SW_REGIONS as $kreise) { + if (in_array($parts[1], $kreise, true)) { + return true; + } + } + } + return false; + } + return false; +} + +function fav_toggle(int $userId, string $kind, string $ref): bool +{ + if (!fav_valid($kind, $ref)) { + throw new DomainException('flash.invalid_input'); + } + $exists = SW::$db->one('SELECT 1 FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); + if ($exists !== null) { + SW::$db->run('DELETE FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); + return false; + } + SW::$db->run( + 'INSERT INTO favorites (user_id, kind, ref, created_at) VALUES (?, ?, ?, ?)', + [$userId, $kind, $ref, Clock::nowStr()] + ); + return true; +} + +function fav_is(int $userId, string $kind, string $ref): bool +{ + return null !== SW::$db->one('SELECT 1 FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); +} + +function fav_list(int $userId): array +{ + return SW::$db->all( + 'SELECT f.kind, f.ref, c.name_de, c.name_en, t.title AS topic_title, t.status AS topic_status + FROM favorites f + LEFT JOIN categories c ON f.kind = \'category\' AND c.slug = f.ref + LEFT JOIN topics t ON f.kind = \'topic\' AND t.id = CAST(f.ref AS INTEGER) + WHERE f.user_id = ? + ORDER BY f.kind, f.ref', + [$userId] + ); +} + +const SW_LAWS = [ + 'stgb-130-1' => [ + 'norm' => '§ 130 Abs. 1 StGB', 'titel' => 'Volksverhetzung', + 'schlagworte' => 'volksverhetzung hass hetze aufstacheln menschenwürde gruppe bevölkerung', + 'text' => 'Wer in einer Weise, die geeignet ist, den öffentlichen Frieden zu stören, 1. gegen eine nationale, rassische, religiöse oder durch ihre ethnische Herkunft bestimmte Gruppe, gegen Teile der Bevölkerung oder gegen einen Einzelnen wegen dessen Zugehörigkeit zu einer vorbezeichneten Gruppe oder zu einem Teil der Bevölkerung zum Hass aufstachelt, zu Gewalt- oder Willkürmaßnahmen auffordert oder 2. die Menschenwürde anderer dadurch angreift, dass er eine vorbezeichnete Gruppe, Teile der Bevölkerung oder einen Einzelnen wegen dessen Zugehörigkeit zu einer vorbezeichneten Gruppe oder zu einem Teil der Bevölkerung beschimpft, böswillig verächtlich macht oder verleumdet, wird mit Freiheitsstrafe von drei Monaten bis zu fünf Jahren bestraft.', + ], + 'stgb-86a-1' => [ + 'norm' => '§ 86a Abs. 1 StGB', 'titel' => 'Verwenden von Kennzeichen verfassungswidriger und terroristischer Organisationen', + 'schlagworte' => 'kennzeichen symbole verfassungswidrig hakenkreuz parole organisation', + 'text' => 'Mit Freiheitsstrafe bis zu drei Jahren oder mit Geldstrafe wird bestraft, wer 1. im Inland Kennzeichen einer der in § 86 Abs. 1 Nr. 1, 2 und 4 oder Absatz 2 bezeichneten Parteien oder Vereinigungen verbreitet oder öffentlich, in einer Versammlung oder in einem von ihm verbreiteten Inhalt (§ 11 Absatz 3) verwendet oder 2. einen Inhalt (§ 11 Absatz 3), der ein derartiges Kennzeichen darstellt oder enthält, zur Verbreitung oder Verwendung im Inland oder Ausland in der in Nummer 1 bezeichneten Art und Weise herstellt, vorrätig hält, einführt oder ausführt.', + ], + 'stgb-111-1' => [ + 'norm' => '§ 111 Abs. 1 StGB', 'titel' => 'Öffentliche Aufforderung zu Straftaten', + 'schlagworte' => 'aufforderung straftat aufruf gewalt anstiftung', + 'text' => 'Wer öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) zu einer rechtswidrigen Tat auffordert, wird wie ein Anstifter (§ 26) bestraft.', + ], + 'stgb-185' => [ + 'norm' => '§ 185 StGB', 'titel' => 'Beleidigung', + 'schlagworte' => 'beleidigung ehre schmähung', + 'text' => 'Die Beleidigung wird mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe und, wenn die Beleidigung öffentlich, in einer Versammlung, durch Verbreiten eines Inhalts (§ 11 Absatz 3) oder mittels einer Tätlichkeit begangen wird, mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-186' => [ + 'norm' => '§ 186 StGB', 'titel' => 'Üble Nachrede', + 'schlagworte' => 'üble nachrede tatsache herabwürdigen rufschädigung', + 'text' => 'Wer in Beziehung auf einen anderen eine Tatsache behauptet oder verbreitet, welche denselben verächtlich zu machen oder in der öffentlichen Meinung herabzuwürdigen geeignet ist, wird, wenn nicht diese Tatsache erweislich wahr ist, mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe und, wenn die Tat öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) begangen ist, mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-187' => [ + 'norm' => '§ 187 StGB', 'titel' => 'Verleumdung', + 'schlagworte' => 'verleumdung unwahre tatsache lüge kredit', + 'text' => 'Wer wider besseres Wissen in Beziehung auf einen anderen eine unwahre Tatsache behauptet oder verbreitet, welche denselben verächtlich zu machen oder in der öffentlichen Meinung herabzuwürdigen oder dessen Kredit zu gefährden geeignet ist, wird mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe und, wenn die Tat öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) begangen ist, mit Freiheitsstrafe bis zu fünf Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-240-1' => [ + 'norm' => '§ 240 Abs. 1 StGB', 'titel' => 'Nötigung', + 'schlagworte' => 'nötigung zwang drohung übel', + 'text' => 'Wer einen Menschen rechtswidrig mit Gewalt oder durch Drohung mit einem empfindlichen Übel zu einer Handlung, Duldung oder Unterlassung nötigt, wird mit Freiheitsstrafe bis zu drei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-241-1' => [ + 'norm' => '§ 241 Abs. 1 StGB', 'titel' => 'Bedrohung', + 'schlagworte' => 'bedrohung drohung verbrechen gewalt', + 'text' => 'Wer einen Menschen mit der Begehung einer gegen ihn oder eine ihm nahestehende Person gerichteten rechtswidrigen Tat gegen die sexuelle Selbstbestimmung, die körperliche Unversehrtheit, die persönliche Freiheit oder gegen eine Sache von bedeutendem Wert bedroht, wird mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe bestraft.', + ], + 'stgb-126a-1' => [ + 'norm' => '§ 126a Abs. 1 StGB', 'titel' => 'Gefährdendes Verbreiten personenbezogener Daten', + 'schlagworte' => 'doxxing personenbezogene daten adresse veröffentlichen gefährdung', + 'text' => 'Wer personenbezogene Daten einer anderen Person in einer Art und Weise, die geeignet ist, diese Person oder eine ihr nahestehende Person der Gefahr 1. eines gegen sie gerichteten Verbrechens oder 2. einer sonstigen gegen sie gerichteten rechtswidrigen Tat gegen die körperliche Unversehrtheit, die persönliche Freiheit oder gegen eine Sache von bedeutendem Wert auszusetzen, öffentlich zugänglich macht, wird mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], +]; + +function law_search(string $q): array +{ + $q = mb_strtolower(trim($q)); + if ($q === '') { + return []; + } + $hits = []; + foreach (SW_LAWS as $id => $law) { + $haystack = mb_strtolower($law['norm'] . ' ' . $law['titel'] . ' ' . $law['schlagworte'] . ' ' . $law['text']); + if (mb_strpos($haystack, $q) !== false || mb_strpos(str_replace(['§', ' '], '', $haystack), str_replace(['§', ' '], '', $q)) !== false) { + $hits[$id] = $law; + } + } + return $hits; +} + +function report_open_for(int $topicId): ?array +{ + return SW::$db->one( + "SELECT * FROM reports WHERE topic_id = ? AND status IN ('pending','voting')", + [$topicId] + ); +} + +function reports_by(int $userId): array +{ + return SW::$db->all( + 'SELECT r.id, r.status, r.created_at, r.decided_at, t.id AS topic_id, t.title + FROM reports r JOIN topics t ON t.id = r.topic_id + WHERE r.reporter_id = ? + ORDER BY r.created_at DESC', + [$userId] + ); +} + +function reports_today_by(int $reporterId): int +{ + $dayEnd = Clock::nextLocalMidnightUtcStr(); + $dayStart = Clock::addDaysStr($dayEnd, -1); + return (int) SW::$db->val( + 'SELECT COUNT(*) FROM reports WHERE reporter_id = ? AND created_at >= ? AND created_at < ?', + [$reporterId, $dayStart, $dayEnd] + ); +} + +function jury_draw(int $reporterId, int $authorId, int $totalUsers): array +{ + $eligible = SW::$db->all( + "SELECT u.id FROM users u + WHERE u.is_system = 0 + AND u.id NOT IN (?, ?) + AND (u.jury_cooldown_until IS NULL OR u.jury_cooldown_until <= ?) + AND u.id NOT IN ( + SELECT rj.user_id FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + WHERE r.status IN ('pending','voting') + )", + [$reporterId, $authorId, Clock::nowStr()] + ); + $ids = array_map(static function (array $row): int { + return (int) $row['id']; + }, $eligible); + $target = max((int) SW::$cfg['jury_min'], (int) ceil($totalUsers * (float) SW::$cfg['jury_share'])); + $count = count($ids); + if ($count <= $target) { + return $ids; + } + for ($i = $count - 1; $i > 0; $i--) { + $j = random_int(0, $i); + $tmp = $ids[$i]; + $ids[$i] = $ids[$j]; + $ids[$j] = $tmp; + } + return array_slice($ids, 0, $target); +} + +function report_create(int $topicId, int $reporterId, string $lawId): int +{ + if (!isset(SW_LAWS[$lawId])) { + throw new DomainException('flash.report_no_law'); + } + $topic = SW::$db->one('SELECT id, author_id, status FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || $topic['status'] !== 'active') { + throw new DomainException('flash.topic_not_reportable'); + } + return SW::$db->tx(function () use ($topicId, $reporterId, $lawId, $topic): int { + if (report_open_for($topicId) !== null) { + throw new DomainException('flash.report_already_open'); + } + $mine = SW::$db->one('SELECT 1 FROM reports WHERE topic_id = ? AND reporter_id = ?', [$topicId, $reporterId]); + if ($mine !== null) { + throw new DomainException('flash.report_duplicate'); + } + if (reports_today_by($reporterId) >= (int) SW::$cfg['reports_per_day']) { + throw new DomainException('flash.report_daily_limit'); + } + $totalUsers = (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'); + $jurors = jury_draw($reporterId, (int) $topic['author_id'], $totalUsers); + if ($jurors === []) { + throw new DomainException('flash.report_too_few_users'); + } + $quorum = min( + count($jurors), + max((int) SW::$cfg['quorum_min'], (int) ceil($totalUsers * (float) SW::$cfg['quorum_share'])) + ); + SW::$db->run( + 'INSERT INTO reports (topic_id, reporter_id, criteria, freetext, status, + jury_size, quorum, created_at, voting_starts_at) + VALUES (?, ?, ?, ?, \'pending\', ?, ?, ?, ?)', + [$topicId, $reporterId, $lawId, null, + count($jurors), $quorum, Clock::nowStr(), Clock::nextLocalMidnightUtcStr()] + ); + $reportId = SW::$db->lastId(); + foreach ($jurors as $jurorId) { + SW::$db->run('INSERT INTO report_jurors (report_id, user_id) VALUES (?, ?)', [$reportId, $jurorId]); + } + return $reportId; + }); +} + +function jury_pending_for(int $userId): ?array +{ + return SW::$db->one( + "SELECT r.*, t.title, t.goal, t.reasoning + FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + JOIN topics t ON t.id = r.topic_id + WHERE rj.user_id = ? AND rj.vote IS NULL AND r.status = 'voting' + ORDER BY r.voting_starts_at + LIMIT 1", + [$userId] + ); +} + +function jury_upcoming_for(int $userId): ?array +{ + return SW::$db->one( + "SELECT r.id, r.voting_starts_at + FROM report_jurors rj JOIN reports r ON r.id = rj.report_id + WHERE rj.user_id = ? AND rj.vote IS NULL AND r.status = 'pending' + ORDER BY r.voting_starts_at LIMIT 1", + [$userId] + ); +} + +function jury_tally(int $reportId): array +{ + $row = SW::$db->one( + "SELECT COUNT(*) AS seats, COUNT(vote) AS cast, + SUM(CASE WHEN vote = 'confirm' THEN 1 ELSE 0 END) AS confirm, + SUM(CASE WHEN vote = 'reject' THEN 1 ELSE 0 END) AS reject, + SUM(CASE WHEN vote = 'neutral' THEN 1 ELSE 0 END) AS neutral + FROM report_jurors WHERE report_id = ?", + [$reportId] + ); + return [ + 'seats' => (int) ($row['seats'] ?? 0), + 'cast' => (int) ($row['cast'] ?? 0), + 'confirm' => (int) ($row['confirm'] ?? 0), + 'reject' => (int) ($row['reject'] ?? 0), + 'neutral' => (int) ($row['neutral'] ?? 0), + ]; +} + +function jury_deadline(array $report): string +{ + return Clock::addHoursStr((string) $report['voting_starts_at'], (int) SW::$cfg['report_vote_hours']); +} + +function jury_decide_if_due(array $report): void +{ + if (Clock::nowStr() < jury_deadline($report)) { + return; + } + $tally = jury_tally((int) $report['id']); + $quorumEffective = min((int) $report['quorum'], $tally['seats']); + if ($tally['cast'] < $quorumEffective) { + return; + } + $removed = $tally['confirm'] > $tally['reject']; + $now = Clock::nowStr(); + SW::$db->run( + 'UPDATE reports SET status = ?, decided_at = ? WHERE id = ?', + [$removed ? 'decided_removed' : 'decided_kept', $now, (int) $report['id']] + ); + if ($removed) { + SW::$db->run("UPDATE topics SET status = 'removed' WHERE id = ?", [(int) $report['topic_id']]); + } + $cooldownUntil = Clock::addDaysStr($now, (int) SW::$cfg['jury_cooldown_days']); + SW::$db->run( + 'UPDATE users SET jury_cooldown_until = ? + WHERE id IN (SELECT user_id FROM report_jurors WHERE report_id = ?)', + [$cooldownUntil, (int) $report['id']] + ); +} + +function jury_cast(int $reportId, int $userId, string $vote): void +{ + if (!in_array($vote, ['confirm', 'reject', 'neutral'], true)) { + throw new DomainException('flash.invalid_input'); + } + SW::$db->tx(function () use ($reportId, $userId, $vote): void { + $report = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$reportId]); + if ($report === null || $report['status'] !== 'voting') { + throw new DomainException('flash.jury_not_open'); + } + $seat = SW::$db->one('SELECT vote FROM report_jurors WHERE report_id = ? AND user_id = ?', [$reportId, $userId]); + if ($seat === null) { + throw new DomainException('flash.jury_not_member'); + } + if ($seat['vote'] !== null) { + throw new DomainException('flash.jury_already_voted'); + } + SW::$db->run( + 'UPDATE report_jurors SET vote = ?, voted_at = ? WHERE report_id = ? AND user_id = ?', + [$vote, Clock::nowStr(), $reportId, $userId] + ); + jury_decide_if_due($report); + }); +} + +function maintenance_tick(): void +{ + SW::$db->run( + "UPDATE topics SET status = 'closed' + WHERE status = 'active' AND end_date IS NOT NULL AND end_date < ?", + [Clock::localDate()] + ); + SW::$db->run( + "UPDATE reports SET status = 'voting' WHERE status = 'pending' AND voting_starts_at <= ?", + [Clock::nowStr()] + ); + foreach (SW::$db->all("SELECT * FROM reports WHERE status = 'voting'") as $report) { + SW::$db->tx(function () use ($report): void { + jury_decide_if_due($report); + }); + } + rate_gc(); +} + +function maintenance_tick_throttled(): void +{ + $now = Clock::now()->getTimestamp(); + $last = (int) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'last_tick'") ?? 0); + if (($now - $last) < 30) { + return; + } + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('last_tick', ?) + ON CONFLICT(k) DO UPDATE SET v = excluded.v", + [(string) $now] + ); + maintenance_tick(); +} + +function account_delete(int $userId): void +{ + SW::$db->tx(function () use ($userId): void { + $systemId = (int) SW::$db->val('SELECT id FROM users WHERE is_system = 1 LIMIT 1'); + SW::$db->run('UPDATE topics SET author_id = ? WHERE author_id = ?', [$systemId, $userId]); + SW::$db->run('DELETE FROM users WHERE id = ?', [$userId]); + }); +} + +const SW_DE = [ + 'app.tagline' => 'Digitale Bürgerbeteiligung', + 'banner.test' => 'Testbetrieb – keine offizielle Seite der Bundesregierung oder einer Behörde.', + 'a11y.skip' => 'Zum Inhalt springen', + 'nav.topics' => 'Themen', + 'nav.jury' => 'Jury', + 'auth.login' => 'Ausweis auflegen', + 'auth.logout' => 'Abmelden', + 'common.date_format' => 'd.m.Y', + 'common.datetime_format' => 'd.m.Y, H:i', + 'common.back_home' => 'Zur Startseite', + + 'topics.filter_category' => 'Kategorie', + 'topics.filter_all' => 'Alle', + 'topics.search' => 'Suche', + 'topics.sort_net' => 'Größte Zustimmung', + 'topics.sort' => 'Sortierung', + 'topics.sort_new' => 'Neueste', + 'topics.sort_top' => 'Meiste Stimmen', + 'topics.apply' => 'Filtern', + 'topics.none' => 'Keine Themen gefunden.', + 'topics.prev' => 'Zurück', + 'topics.next' => 'Weiter', + 'topics.page_of' => 'Seite {p} von {n}', + + 'scope.bund' => 'Deutschland', + 'scope.bundesland' => 'Bundesland', + 'scope.landkreis' => 'Landkreis', + + 'topic.goal_label' => 'Ziel', + 'topic.reasoning_label' => 'Begründung', + 'topic.report_link' => 'Inhalt melden', + 'topic.report_open' => 'Gemeinschaftsprüfung läuft.', + 'topic.remember' => 'Merken', + 'topic.this' => 'Dieses Thema', + 'topic.removed_title' => 'Inhalt entfernt', + 'topic.removed_text' => 'Dieser Beitrag wurde nach Prüfung durch eine ausgeloste Bürger-Jury entfernt.', + 'topic.your_vote' => 'Ihre Stimme: {choice}', + + 'vote.for' => 'Dafür', + 'vote.against' => 'Dagegen', + 'vote.withdraw' => 'Stimme zurückziehen', + 'vote.login_hint' => 'Zum Abstimmen Ausweis auflegen', + 'vote.bar_aria' => 'Abstimmungsergebnis', + + 'topic.new_title' => 'Thema einbringen', + 'topic.posted_today' => 'Heute bereits ein Thema eingebracht. Das nächste ist ab 00:00 Uhr möglich.', + 'topic.next_in' => 'Nächstes Thema in', + 'common.close' => 'Schließen', + 'topics.clear' => 'Filter zurücksetzen', + 'scope.whole' => 'gesamt', + 'topic.f_end' => 'Ende der Abstimmung', + 'topic.end_by_date' => 'an einem Datum', + 'topic.end_by_target' => 'bei erreichter Stimmenzahl', + 'topic.end_value_ph' => 'Anzahl', + 'topic.end_unit' => 'Einheit', + 'topic.end_unit_count' => 'X Stimmen', + 'topic.end_unit_percent' => '% Stimmen', + 'topic.err_end' => 'Bitte ein gültiges Ende angeben (Datum in der Zukunft oder Zielzahl).', + 'topic.ends_on' => 'Läuft bis {date}', + 'topic.ends_count' => '{have} von {target} Stimmen', + 'topic.ends_both' => 'Läuft bis {date} oder {have} von {target} Stimmen', + 'topic.ended' => 'beendet', + 'topic.edit' => 'Thema bearbeiten', + 'topic.save' => 'Änderungen speichern', + 'topic.archive' => 'Thema archivieren', + 'topic.archive_confirm' => 'Das Thema verschwindet aus den Listen und ist nicht mehr wählbar. Gelöscht wird es nicht.', + 'topic.archived_badge' => 'Archiviert', + 'topic.archived_note' => 'Dieses Thema wurde vom Verfasser archiviert.', + 'home.recent_votes' => 'Kürzlich abgestimmt (noch änderbar)', + 'vote.changeable_until' => 'änderbar bis {date}', + 'topic.f_title' => 'Titel', + 'topic.f_goal' => 'Ziel', + 'topic.f_reasoning' => 'Begründung', + 'topic.f_category' => 'Kategorie', + 'topic.f_scope' => 'Geltungsbereich', + 'topic.f_choose' => 'Bitte wählen', + 'topic.submit' => 'Veröffentlichen', + 'topic.err_title' => 'Titel: mindestens 8 Zeichen.', + 'topic.err_goal' => 'Ziel: mindestens 10 Zeichen.', + 'topic.err_reasoning' => 'Begründung: mindestens 10 Zeichen.', + 'topic.err_category' => 'Bitte eine Kategorie wählen.', + 'topic.err_scope' => 'Bitte einen Geltungsbereich wählen.', + + 'auth.with' => 'Mit {app} anmelden', + 'testmode.chip' => 'Testmodus', + 'flash.testmode_ended' => 'Echtbetrieb eingerichtet, Testdaten gelöscht.', + 'flash.testmode_confirm' => 'Bitte das Beenden bestätigen.', + 'setup.title' => 'Echtbetrieb einrichten', + 'setup.checks' => 'Voraussetzungen', + 'setup.check_data' => 'Datenverzeichnis beschreibbar', + 'setup.check_https' => 'HTTPS aktiv', + 'setup.check_sodium' => 'Kryptographie (sodium) verfügbar', + 'setup.check_htaccess' => 'Zugriffsschutz (.htaccess) vorhanden', + 'setup.check_keys' => 'Freigegebene Ausweis-Schlüssel: {n}', + 'setup.path' => 'Zugang', + 'setup.path_eid' => 'Eigener eID-Server', + 'setup.path_list' => 'Eigene Trust-Liste', + 'setup.f_server' => 'eID-Server (SOAP-Adresse)', + 'setup.f_cert' => 'Client-Zertifikat (Pfad)', + 'setup.f_key' => 'Privater Schlüssel (Pfad)', + 'setup.f_client' => 'Ausweis-App auf dem Gerät', + 'setup.f_sync' => 'Abgleich-Adresse der Freigabeliste', + 'setup.f_nect' => 'Nect Wallet: Startadresse', + 'setup.token' => 'Einrichtungsschlüssel', + 'setup.token_hint' => 'data/setup.token', + 'setup.check_btn' => 'Verbindung prüfen', + 'setup.finish_btn' => 'Umschalten', + 'setup.confirm' => 'Testdaten löschen', + 'setup.end_locked' => 'Umschalten nur durch Bearbeiten der Datei.', + 'setup.err_client' => 'Adresse der Ausweis-App ist ungültig.', + 'setup.err_https' => 'Adressen müssen mit https:// beginnen.', + 'setup.err_file' => 'Zertifikat oder Schlüssel ist nicht lesbar.', + 'setup.err_server_missing' => 'Für den eID-Server wird dessen SOAP-Adresse benötigt.', + 'setup.err_list_empty' => 'Die Freigabeliste ist leer: Abgleich-Adresse angeben oder zuerst Ausweise ausgeben.', + 'setup.err_token' => 'Einrichtungsschlüssel stimmt nicht.', + 'setup.err_check_first' => 'Bitte zuerst die Verbindung prüfen.', + 'setup.err_save' => 'Einstellungen nicht speicherbar – Rechte des Ordners data/ prüfen.', + 'flash.setup_ok' => 'eID-Server antwortet.', + 'flash.setup_failed' => 'eID-Server antwortet nicht oder liefert keine Sitzung.', + 'flash.setup_no_server' => 'Keine eID-Server-Adresse angegeben.', + 'flash.setup_list_ok' => 'Trust-Liste erreichbar: {n} Schlüssel.', + 'flash.setup_list_failed' => 'Trust-Liste nicht erreichbar.', + 'auth.title' => 'Mit Ausweis anmelden', + 'auth.tap' => 'Ausweis auflegen', + 'auth.test_login' => 'Test-Anmeldung starten', + 'auth.hold' => 'Ausweis an das Gerät halten …', + + 'me.jury_upcoming' => 'Ausgelost; Abstimmung ab {date}, 00:00 Uhr.', + + 'jury.title' => 'Bürger-Jury', + 'jury.intro' => 'Per Los ausgewählt. Bitte anhand der Kriterien bewerten; Enthaltung zulässig.', + 'jury.blocked' => 'Bis zur Stimmabgabe sind die übrigen Funktionen gesperrt.', + 'jury.none' => 'Keine Jury-Aufgabe.', + 'jury.upcoming' => 'Ausgelost; Abstimmung ab {date}, 00:00 Uhr. Bis dahin ist nichts zu tun.', + 'jury.reported' => 'Gemeldeter Inhalt', + 'jury.law' => 'Gemeldeter Gesetzesverstoß', + 'jury.question' => 'Verstößt der Inhalt gegen das zitierte Gesetz?', + 'jury.confirm' => 'Ja – entfernen', + 'jury.reject' => 'Nein – behalten', + 'jury.neutral' => 'Enthaltung', + 'jury.stats' => '{cast} von {seats} Stimmen abgegeben · Quorum: {quorum}', + 'jury.deadline' => 'Reguläres Ende: {date}; danach wird bei erreichtem Quorum entschieden.', + + 'report.title' => 'Inhalt melden', + 'report.intro' => 'Einziger Meldegrund: Verstoß gegen ein Gesetz. Der verletzte Paragraph wird 1:1 zitiert.', + 'report.process' => 'Es entscheidet eine ausgeloste Bürger-Jury (1 %; ab 00:00 Uhr, 24 h, Quorum 0,5 %).', + 'report.search' => 'Gesetz finden (Schlagwort oder Paragraph)', + 'report.pick' => 'Verletztes Gesetz (wird 1:1 zitiert)', + 'report.none_found' => 'Kein Treffer. Anderes Schlagwort oder Paragraphennummer versuchen.', + 'report.submit' => 'Meldung abschicken', + 'report.cancel' => 'Abbrechen', + + 'flash.session_expired' => 'Sitzung beendet. Bitte Ausweis erneut auflegen.', + 'flash.auth_expired' => 'Anmeldung abgelaufen – bitte Ausweis erneut auflegen.', + 'flash.login_required' => 'Bitte zuerst den Ausweis auflegen.', + 'flash.card_required' => 'Bestätigung fehlgeschlagen. Bitte Ausweis erneut auflegen.', + 'flash.rate_limited' => 'Zu viele Anfragen. Bitte kurz warten.', + 'flash.csrf' => 'Anfrage konnte nicht zugeordnet werden. Bitte erneut versuchen.', + 'flash.invalid_input' => 'Ungültige Eingabe.', + 'flash.topic_daily_limit' => 'Heute bereits ein Thema eingebracht; das nächste ab 00:00 Uhr.', + 'flash.topic_created' => 'Thema veröffentlicht.', + 'flash.topic_not_votable' => 'Abstimmung nicht möglich.', + 'flash.topic_not_reportable' => 'Meldung nicht möglich.', + 'flash.vote_saved' => 'Stimme gespeichert.', + 'flash.vote_withdrawn' => 'Stimme zurückgezogen.', + 'flash.favorite_added' => 'Favorit hinzugefügt.', + 'flash.favorite_removed' => 'Favorit entfernt.', + 'flash.report_already_open' => 'Für dieses Thema läuft bereits eine Prüfung.', + 'flash.report_duplicate' => 'Dieses Thema wurde von Ihnen bereits gemeldet.', + 'flash.report_daily_limit' => 'Tageslimit für Meldungen erreicht.', + 'flash.report_too_few_users' => 'Für eine Jury sind derzeit zu wenige Teilnehmende registriert.', + 'flash.report_no_law' => 'Bitte das verletzte Gesetz auswählen.', + 'flash.not_author' => 'Nur der Verfasser kann dieses Thema ändern.', + 'flash.topic_locked' => 'Sobald abgestimmt wurde, bleibt das Thema dauerhaft bestehen.', + 'topic.similar' => 'Ähnliche Themen', + 'topic.similar_hint' => 'Zu diesem Titel gibt es bereits ähnliche Themen. Einbringen ist trotzdem möglich.', + 'flash.topic_updated' => 'Thema aktualisiert.', + 'flash.topic_archived' => 'Thema archiviert.', + 'flash.vote_locked' => 'Diese Stimme ist nach 24 Stunden nicht mehr änderbar.', + 'flash.report_created' => 'Meldung aufgenommen. Die Jury ist ausgelost; Abstimmung ab 00:00 Uhr.', + 'flash.jury_not_open' => 'Diese Abstimmung ist nicht (mehr) offen.', + 'flash.jury_not_member' => 'Keine Berechtigung für diese Jury.', + 'flash.jury_already_voted' => 'In dieser Prüfung wurde bereits abgestimmt.', + 'flash.jury_voted' => 'Jury-Stimme gezählt.', + 'flash.auth_failed' => 'Anmeldung fehlgeschlagen.', + 'flash.eid_required' => 'Anmeldung nur mit echtem Ausweis über eine Ausweis-App.', + 'flash.eid_provider_off' => 'Dieser Anbieter ist in dieser Installation noch nicht eingerichtet.', + 'flash.no_card' => 'Kein Ausweis vorhanden. Bitte Ausweis bereitstellen.', + 'flash.card_not_authorized' => 'Dieser Ausweis ist nicht autorisiert.', + 'flash.card_ready' => 'Ausweis bereit. Zum Anmelden auflegen.', + 'flash.card_new' => 'Bereit für einen anderen Ausweis. Zum Anmelden auflegen.', + 'flash.logged_out' => 'Abgemeldet.', + + 'error.not_found_title' => 'Seite nicht gefunden', + 'error.not_found' => 'Die angeforderte Seite existiert nicht oder wurde entfernt.', + 'error.generic_title' => 'Fehler', + 'error.generic' => 'Es ist ein Fehler aufgetreten. Bitte später erneut versuchen.', + 'error.method' => 'Anfrageart nicht unterstützt.', + + 'footer.imprint' => 'Impressum', + 'footer.privacy' => 'Datenschutz', + + 'imprint.h' => 'Impressum', + 'imprint.p1' => "Florian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + 'imprint.p2' => 'E-Mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Verantwortlich für den Inhalt:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + + 'privacy.h' => 'Datenschutzerklärung', + 'privacy.p1' => 'Diese Anwendung verarbeitet keine Klaridentitäten wie Name, Anschrift, Geburtsdatum oder E-Mail-Adresse.', + 'privacy.p2' => 'Zur Nutzung der Seite wird ein technisches Profil erstellt. Dieses Profil sowie die auf der Seite abgegebenen Eingaben, Stimmen, Themen, Meldungen und Entscheidungen werden dauerhaft gespeichert. Die dauerhafte Speicherung dient der Eindeutigkeit, der Nachvollziehbarkeit, der Verhinderung mehrfacher oder nachträglich manipulierter Vorgänge und der Sicherheit der Anwendung.', + 'privacy.p3' => 'Zur Wiedererkennung verwendet die Anwendung pseudonyme technische Kennungen, zum Beispiel Hash- oder HMAC-Werte mit serverseitigem Geheimnis. Diese Kennungen dienen der Anmeldung, Sitzungsführung, Missbrauchsabwehr und Verhinderung von Doppelstimmen.', + 'privacy.p4' => 'Die Seite verwendet ein technisch notwendiges Sitzungs-Cookie für Anmeldung und Sicherheit. Weitere Cookies, Tracking, Werbung und externe Drittinhalte werden nicht eingesetzt.', + 'privacy.p5' => 'Zur Missbrauchsabwehr und Fehleranalyse können technische Protokolldaten verarbeitet werden. Diese werden nur für Sicherheit und Betrieb verwendet.', + 'privacy.p6' => 'Rechtsgrundlage ist, soweit erforderlich, Art. 6 Abs. 1 lit. f DSGVO. Das berechtigte Interesse liegt im sicheren Betrieb der Anwendung, der Eindeutigkeit der Abstimmungen und dem Schutz vor Manipulation.', + 'privacy.p7' => 'Betroffene Personen haben nach Maßgabe der DSGVO Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Widerspruch und Beschwerde bei einer Datenschutzaufsichtsbehörde.', + 'privacy.p8' => 'Löschung und Einschränkung der Verarbeitung können abgelehnt oder nur eingeschränkt umgesetzt werden, soweit die weitere Speicherung zur Integrität, Nachvollziehbarkeit, Missbrauchsabwehr, Verhinderung von Doppelstimmen oder Verhinderung nachträglicher Manipulation erforderlich ist.', + 'privacy.p9' => 'Nutzer sind für die von ihnen eingestellten Inhalte selbst verantwortlich. Der Betreiber macht sich diese Inhalte nicht zu eigen. Rechtswidrige Inhalte können nach Kenntnisnahme geprüft, eingeschränkt oder entfernt werden.', + 'privacy.p10' => "Verantwortlich:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]\nE-Mail: datenschutz@buergerabstimmung.org", + 'consent.text' => 'Diese Seite verwendet ein technisch notwendiges Sitzungs-Cookie für Anmeldung und Sicherheit. Details entnehmen Sie der Datenschutzerklärung.', + 'consent.accept' => 'Akzeptieren', + 'consent.privacy' => 'Datenschutzerklärung', + 'error.consent' => 'Dafür wird das Sitzungs-Cookie benötigt. Bitte unten zustimmen.', +]; + +const SW_EN = [ + 'app.tagline' => 'Digital citizen participation', + 'banner.test' => 'Test operation – not an official website of the German federal government or any public authority.', + 'a11y.skip' => 'Skip to content', + 'nav.topics' => 'Topics', + 'nav.jury' => 'Jury', + 'auth.login' => 'Place your ID card', + 'auth.logout' => 'Sign out', + 'common.date_format' => 'Y-m-d', + 'common.datetime_format' => 'Y-m-d, H:i', + 'common.back_home' => 'Back to start page', + + 'topics.filter_category' => 'Category', + 'topics.filter_all' => 'All', + 'topics.search' => 'Search', + 'topics.sort_net' => 'Highest approval', + 'topics.sort' => 'Sort', + 'topics.sort_new' => 'Newest', + 'topics.sort_top' => 'Most votes', + 'topics.apply' => 'Apply', + 'topics.none' => 'No topics found.', + 'topics.prev' => 'Previous', + 'topics.next' => 'Next', + 'topics.page_of' => 'Page {p} of {n}', + + 'scope.bund' => 'Germany', + 'scope.bundesland' => 'Federal state', + 'scope.landkreis' => 'District', + + 'topic.goal_label' => 'Goal', + 'topic.reasoning_label' => 'Reasoning', + 'topic.report_link' => 'Report content', + 'topic.report_open' => 'Community review in progress.', + 'topic.remember' => 'Save', + 'topic.this' => 'This topic', + 'topic.removed_title' => 'Content removed', + 'topic.removed_text' => 'This contribution was removed after review by a randomly drawn citizen jury.', + 'topic.your_vote' => 'Your vote: {choice}', + + 'vote.for' => 'For', + 'vote.against' => 'Against', + 'vote.withdraw' => 'Withdraw vote', + 'vote.login_hint' => 'Place your ID card to vote', + 'vote.bar_aria' => 'Voting result', + + 'topic.new_title' => 'Raise a topic', + 'common.close' => 'Close', + 'topics.clear' => 'Reset filters', + 'scope.whole' => 'whole', + 'topic.f_end' => 'End of voting', + 'topic.end_by_date' => 'on a date', + 'topic.end_by_target' => 'at a number of votes', + 'topic.end_value_ph' => 'Amount', + 'topic.end_unit' => 'Unit', + 'topic.end_unit_count' => 'X votes', + 'topic.end_unit_percent' => '% votes', + 'topic.err_end' => 'Please set a valid end (future date or target count).', + 'topic.ends_on' => 'Runs until {date}', + 'topic.ends_count' => '{have} of {target} votes', + 'topic.ends_both' => 'Runs until {date} or {have} of {target} votes', + 'topic.ended' => 'ended', + 'topic.edit' => 'Edit topic', + 'topic.save' => 'Save changes', + 'topic.archive' => 'Archive topic', + 'topic.archive_confirm' => 'The topic disappears from the lists and can no longer be voted on. It is not deleted.', + 'topic.archived_badge' => 'Archived', + 'topic.archived_note' => 'This topic was archived by its author.', + 'home.recent_votes' => 'Recently voted (still changeable)', + 'vote.changeable_until' => 'changeable until {date}', + 'topic.posted_today' => 'You already raised a topic today. The next one is possible from midnight.', + 'topic.next_in' => 'Next topic in', + 'topic.f_title' => 'Title', + 'topic.f_goal' => 'Goal', + 'topic.f_reasoning' => 'Reasoning', + 'topic.f_category' => 'Category', + 'topic.f_scope' => 'Jurisdiction', + 'topic.f_choose' => 'Please choose', + 'topic.submit' => 'Publish', + 'topic.err_title' => 'Title: at least 8 characters.', + 'topic.err_goal' => 'Goal: at least 10 characters.', + 'topic.err_reasoning' => 'Reasoning: at least 10 characters.', + 'topic.err_category' => 'Please choose a category.', + 'topic.err_scope' => 'Please choose a jurisdiction.', + + 'auth.with' => 'Sign in with {app}', + 'testmode.chip' => 'Test mode', + 'flash.testmode_ended' => 'Live operation configured, test data deleted.', + 'flash.testmode_confirm' => 'Please confirm ending test mode.', + 'setup.title' => 'Set up live operation', + 'setup.checks' => 'Prerequisites', + 'setup.check_data' => 'Data directory writable', + 'setup.check_https' => 'HTTPS active', + 'setup.check_sodium' => 'Cryptography (sodium) available', + 'setup.check_htaccess' => 'Access protection (.htaccess) present', + 'setup.check_keys' => 'Authorised ID keys: {n}', + 'setup.path' => 'Access', + 'setup.path_eid' => 'Own eID server', + 'setup.path_list' => 'Own trust list', + 'setup.f_server' => 'eID server (SOAP address)', + 'setup.f_cert' => 'Client certificate (path)', + 'setup.f_key' => 'Private key (path)', + 'setup.f_client' => 'ID app on the device', + 'setup.f_sync' => 'Sync address of the allowlist', + 'setup.f_nect' => 'Nect Wallet: start address', + 'setup.token' => 'Setup key', + 'setup.token_hint' => 'data/setup.token', + 'setup.check_btn' => 'Test connection', + 'setup.finish_btn' => 'Switch over', + 'setup.confirm' => 'Delete test data', + 'setup.end_locked' => 'Switching only by editing the file.', + 'setup.err_client' => 'The ID app address is invalid.', + 'setup.err_https' => 'Addresses must start with https://.', + 'setup.err_file' => 'Certificate or key is not readable.', + 'setup.err_server_missing' => 'The eID server needs its SOAP address.', + 'setup.err_list_empty' => 'The allowlist is empty: give a sync address or issue ID cards first.', + 'setup.err_token' => 'Setup key does not match.', + 'setup.err_check_first' => 'Please test the connection first.', + 'setup.err_save' => 'Settings could not be saved – check the permissions of the data/ folder.', + 'flash.setup_ok' => 'eID server responds.', + 'flash.setup_failed' => 'eID server does not respond or returns no session.', + 'flash.setup_no_server' => 'No eID server address given.', + 'flash.setup_list_ok' => 'Trust list reachable: {n} keys.', + 'flash.setup_list_failed' => 'Trust list not reachable.', + 'auth.title' => 'Sign in with ID card', + 'auth.tap' => 'Place your ID card', + 'auth.test_login' => 'Start test sign-in', + 'auth.hold' => 'Hold your ID card to the device …', + + 'me.jury_upcoming' => 'Drawn; voting starts {date}, midnight.', + + 'jury.title' => 'Citizen jury', + 'jury.intro' => 'Drawn by lot. Please assess against the criteria; abstaining is allowed.', + 'jury.blocked' => 'Until you vote, the other functions are locked.', + 'jury.none' => 'No jury task.', + 'jury.upcoming' => 'Drawn; voting starts {date}, midnight. Nothing to do until then.', + 'jury.reported' => 'Reported content', + 'jury.law' => 'Reported violation of law', + 'jury.question' => 'Does the content violate the quoted law?', + 'jury.confirm' => 'Yes – remove', + 'jury.reject' => 'No – keep', + 'jury.neutral' => 'Abstain', + 'jury.stats' => '{cast} of {seats} votes cast · quorum: {quorum}', + 'jury.deadline' => 'Regular end: {date}; afterwards a decision is made once the quorum is reached.', + + 'report.title' => 'Report content', + 'report.intro' => 'The only reason to report: violation of a law. The violated section is quoted verbatim.', + 'report.process' => 'A drawn citizen jury decides (1%; from midnight, 24 h, 0.5% quorum).', + 'report.search' => 'Find the law (keyword or section number)', + 'report.pick' => 'Violated law (quoted verbatim)', + 'report.none_found' => 'No match. Try another keyword or section number.', + 'report.submit' => 'Submit report', + 'report.cancel' => 'Cancel', + + 'flash.session_expired' => 'Session ended. Please tap your ID card again.', + 'flash.auth_expired' => 'Sign-in expired – please tap your ID card again.', + 'flash.login_required' => 'Please tap your ID card first.', + 'flash.card_required' => 'Confirmation failed. Please tap your ID card again.', + 'flash.rate_limited' => 'Too many requests. Please wait a moment.', + 'flash.csrf' => 'The request could not be verified. Please try again.', + 'flash.invalid_input' => 'Invalid input.', + 'flash.topic_daily_limit' => 'Topic already raised today; the next one from midnight.', + 'flash.topic_created' => 'Topic published.', + 'flash.topic_not_votable' => 'Voting not possible.', + 'flash.topic_not_reportable' => 'Reporting not possible.', + 'flash.vote_saved' => 'Vote saved.', + 'flash.vote_withdrawn' => 'Vote withdrawn.', + 'flash.favorite_added' => 'Favourite added.', + 'flash.favorite_removed' => 'Favourite removed.', + 'flash.report_already_open' => 'A review is already in progress for this topic.', + 'flash.report_duplicate' => 'You have already reported this topic.', + 'flash.report_daily_limit' => 'Daily report limit reached.', + 'flash.report_too_few_users' => 'Too few participants are registered for a jury at the moment.', + 'flash.report_no_law' => 'Please select the violated law.', + 'flash.not_author' => 'Only the author can change this topic.', + 'flash.topic_locked' => 'Once votes are cast the topic stays permanently.', + 'topic.similar' => 'Similar topics', + 'topic.similar_hint' => 'Similar topics already exist for this title. You can still publish it.', + 'flash.topic_updated' => 'Topic updated.', + 'flash.topic_archived' => 'Topic archived.', + 'flash.vote_locked' => 'This vote can no longer be changed after 24 hours.', + 'flash.report_created' => 'Report received. The jury has been drawn; voting starts at midnight.', + 'flash.jury_not_open' => 'This vote is not (or no longer) open.', + 'flash.jury_not_member' => 'No authorisation for this jury.', + 'flash.jury_already_voted' => 'Already voted in this review.', + 'flash.jury_voted' => 'Jury vote counted.', + 'flash.auth_failed' => 'Sign-in failed.', + 'flash.eid_required' => 'Sign-in only with a real ID card via an ID app.', + 'flash.eid_provider_off' => 'This provider is not set up in this installation yet.', + 'flash.no_card' => 'No ID card present. Please provide an ID card.', + 'flash.card_not_authorized' => 'This ID card is not authorised.', + 'flash.card_ready' => 'ID card ready. Tap to sign in.', + 'flash.card_new' => 'Ready for a different ID card. Tap to sign in.', + 'flash.logged_out' => 'Signed out.', + + 'error.not_found_title' => 'Page not found', + 'error.not_found' => 'The requested page does not exist or has been removed.', + 'error.generic_title' => 'Error', + 'error.generic' => 'An error occurred. Please try again later.', + 'error.method' => 'Request method not supported.', + + 'footer.imprint' => 'Legal notice', + 'footer.privacy' => 'Privacy', + + 'imprint.h' => 'Legal notice', + 'imprint.p1' => "Florian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + 'imprint.p2' => 'E-mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Responsible for the content:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + + 'privacy.h' => 'Privacy policy', + 'privacy.p1' => 'This application does not process clear identities such as name, address, date of birth or e-mail address.', + 'privacy.p2' => 'A technical profile is created in order to use the site. This profile and the entries, votes, topics, reports and decisions submitted on the site are stored permanently. Permanent storage serves uniqueness, traceability, the prevention of duplicate or retroactively manipulated operations, and the security of the application.', + 'privacy.p3' => 'For recognition the application uses pseudonymous technical identifiers, for example hash or HMAC values with a server-side secret. These identifiers serve sign-in, session handling, abuse prevention and the prevention of duplicate votes.', + 'privacy.p4' => 'The site uses one technically necessary session cookie for sign-in and security. No further cookies, tracking, advertising or external third-party content are used.', + 'privacy.p5' => 'Technical log data may be processed for abuse prevention and error analysis. It is used for security and operation only.', + 'privacy.p6' => 'The legal basis, where required, is Art. 6(1)(f) GDPR. The legitimate interest lies in the secure operation of the application, the uniqueness of the votes and protection against manipulation.', + 'privacy.p7' => 'Data subjects have the rights to information, rectification, erasure, restriction of processing, objection and to lodge a complaint with a supervisory authority, as provided by the GDPR.', + 'privacy.p8' => 'Erasure and restriction of processing may be refused or carried out only in part where continued storage is necessary for integrity, traceability, abuse prevention, prevention of duplicate votes or prevention of retroactive manipulation.', + 'privacy.p9' => 'Users are responsible for the content they submit. The operator does not adopt this content as its own. Unlawful content may be reviewed, restricted or removed once it becomes known.', + 'privacy.p10' => "Responsible:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]\nE-mail: datenschutz@buergerabstimmung.org", + 'consent.text' => 'This site uses one technically necessary session cookie for sign-in and security. See the privacy policy for details.', + 'consent.accept' => 'Accept', + 'consent.privacy' => 'Privacy policy', + 'error.consent' => 'This needs the session cookie. Please accept below.', +]; + +const SW_CSS = <<<'CSS' + +:root { + color-scheme: light dark; + --page: #f2f2f7; --surface: #ffffff; --field: #efeff4; + --ink: #000000; --muted: #8e8e93; --sep: #d1d1d6; + --accent: #3a76f0; --accent-ink: #ffffff; --accent-soft: rgba(58,118,240,0.12); + --danger: #d70015; --danger-soft: rgba(215,0,21,0.10); + --vote-for: #3a76f0; --vote-against: #aeaeb2; --track: #e5e5ea; + --warn-bg: #ffd60a; --warn-ink: #1c1c00; + --btn-dim: #dedee4; --btn-dim-ink: #5b5b60; + --btn-locked: #e7effd; --btn-locked-ink: #2c60d0; + --radius: 14px; --radius-sm: 10px; +} +@media (prefers-color-scheme: dark) { + :root { + --page: #000000; --surface: #1c1c1e; --field: #2c2c2e; + --ink: #ffffff; --muted: #98989d; --sep: #38383a; + --accent: #4b86f7; --accent-ink: #ffffff; --accent-soft: rgba(75,134,247,0.22); + --danger: #ff453a; --danger-soft: rgba(255,69,58,0.18); + --vote-for: #4b86f7; --vote-against: #8e8e93; --track: #2c2c2e; + --warn-bg: #ffd60a; --warn-ink: #1c1c00; + --btn-dim: #0f0f11; --btn-dim-ink: #98989d; + --btn-locked: #26334c; --btn-locked-ink: #9dbdfc; + } +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, system-ui, sans-serif; + font-size: 1.0625rem; + line-height: 1.45; + color: var(--ink); + background: var(--page); + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} +.shell { width: 100%; max-width: 44rem; margin: 0 auto; padding: 0 1rem; } +.site-main { flex: 1; padding-top: 1rem; padding-bottom: 3rem; } + +h1 { font-size: 1.6rem; line-height: 1.2; margin: 0.2rem 0 0.7rem; font-weight: 700; letter-spacing: -0.02em; } +h2 { font-size: 1.15rem; line-height: 1.25; margin: 1.5rem 0 0.5rem; font-weight: 650; letter-spacing: -0.01em; } +h3 { font-size: 1.0625rem; margin: 0 0 0.2rem; font-weight: 600; } +p { margin: 0.45rem 0; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; } +.muted { color: var(--muted); font-size: 0.95rem; } + +.skip-link { position: absolute; left: -999px; top: 0; background: var(--surface); color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-sm); z-index: 100; } +.skip-link:focus { left: 0.5rem; top: 0.5rem; } + +.test-banner { + background: var(--warn-bg); color: var(--warn-ink); + text-align: center; font-size: 0.72rem; font-weight: 600; line-height: 1.25; + padding: 0.3rem 0.9rem; letter-spacing: -0.01em; +} + +.site-header { background: var(--surface); border-bottom: 1px solid var(--sep); position: sticky; top: 0; z-index: 50; } +.header-inner { display: flex; align-items: center; gap: 0.6rem; min-height: 3rem; padding-top: 0.4rem; padding-bottom: 0.4rem; } +.brand { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--ink); font-weight: 650; font-size: 1.05rem; letter-spacing: -0.01em; } +.brand:hover { text-decoration: none; } +.brand-mark { width: 1.4rem; height: 1.4rem; color: var(--accent); } +.header-controls { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; } +.testmode-chip { padding: 0.3rem 0.7rem; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); font-size: 0.85rem; font-weight: 650; } +.testmode-chip:hover { text-decoration: none; opacity: 0.9; } +.header-inner { justify-content: flex-end; } +.nav-duty { position: relative; padding: 0.3rem 0.7rem; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 0.9rem; font-weight: 600; } +.nav-duty:hover { text-decoration: none; } +.duty-dot { display: inline-block; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--danger); margin-left: 0.35rem; vertical-align: middle; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; + border: 0; border-radius: 12px; font: inherit; font-weight: 600; font-size: 1rem; + padding: 0.62rem 1.05rem; cursor: pointer; text-decoration: none; + transition: opacity 120ms ease, background-color 120ms ease; + -webkit-tap-highlight-color: transparent; +} +.btn:hover { text-decoration: none; opacity: 0.88; } +.btn:active { opacity: 0.7; } +.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.92rem; border-radius: 10px; } +.btn-big { width: 100%; padding: 0.9rem 1.2rem; font-size: 1.0625rem; border-radius: var(--radius); } +.btn-primary { background: var(--accent); color: var(--accent-ink); } +.btn-outline { background: var(--accent-soft); color: var(--accent); } +.btn-ghost { background: transparent; color: var(--accent); padding-left: 0.6rem; padding-right: 0.6rem; } +.btn-danger { background: var(--danger-soft); color: var(--danger); } +.btn-row { display: flex; gap: 0.6rem; flex-wrap: wrap; } + +.card { background: var(--surface); border-radius: var(--radius); padding: 0.95rem 1.05rem; margin: 0.7rem 0; } +.badge { + display: inline-block; font-size: 0.78rem; font-weight: 600; letter-spacing: -0.01em; + padding: 0.15rem 0.55rem; border-radius: 999px; + background: var(--field); color: var(--muted); white-space: nowrap; +} +.badge-danger { background: var(--danger-soft); color: var(--danger); } + +.flash { + background: var(--surface); color: var(--ink); + border-left: 3px solid var(--accent); border-radius: var(--radius-sm); + padding: 0.7rem 0.9rem; margin: 0.7rem 0; font-size: 0.95rem; +} +.flash-error { border-left-color: var(--danger); } +.flash a { font-weight: 600; } +.plain-list { margin: 0; padding-left: 1.1rem; } + +.row-list { list-style: none; margin: 0; padding: 0; background: var(--surface); border-radius: var(--radius); overflow: hidden; } +.row-item { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 0.7rem; flex-wrap: wrap; padding: 0.75rem 1rem; min-height: 2.75rem; } +.row-item:not(:last-child)::after { content: ""; position: absolute; left: 1rem; right: 0; bottom: 0; height: 1px; background: var(--sep); } +.row-main { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; min-width: 0; } +.row-side { display: flex; align-items: center; gap: 0.35rem; font-size: 0.92rem; flex-wrap: wrap; color: var(--ink); } + +.action-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.2rem 0 0.9rem; } +.action-bar .btn { flex: 1 1 auto; } +.fav-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 0.9rem; } +.fav-chips .btn { background: var(--field); color: var(--ink); font-weight: 500; } +.fav-chip { display: inline-flex; align-items: center; gap: 0.4rem; } +.fav-chip .ico { width: 0.95rem; height: 0.95rem; color: var(--accent); } +.recent-votes { margin-bottom: 1.1rem; } +.recent-votes h2 { margin-top: 0.6rem; } + +.topic-grid { display: grid; grid-template-columns: 1fr; gap: 0.6rem; } +.topic-card { margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.topic-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.topic-card-title { margin: 0; font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em; } +.topic-card-title a { color: var(--ink); } +.topic-card-title a:hover { color: var(--accent); text-decoration: none; } +.topic-card-goal { color: var(--muted); font-size: 0.95rem; margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.topic-card-votes { font-size: 0.9rem; color: var(--muted); margin-top: auto; display: flex; align-items: center; flex-wrap: wrap; gap: 0.15rem; } +.vote-sep { margin: 0 0.35rem; } +.pagination { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 1.3rem 0 0; } + +.topic-detail h1 { margin-top: 0.4rem; } +.field-label { font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0.9rem 0 0.2rem; } +.card .field-label:first-child { margin-top: 0; } +.dot { display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-right: 0.3rem; } +.dot-for { background: var(--vote-for); } +.dot-against { background: var(--vote-against); } +.votebar { display: flex; gap: 2px; height: 0.6rem; border-radius: 999px; overflow: hidden; background: var(--track); } +.votebar span { flex-basis: 0; min-width: 4px; } +.votebar-for { background: var(--vote-for); } +.votebar-against { background: var(--vote-against); } +.votebar-legend { display: flex; flex-wrap: wrap; gap: 0.3rem 1.1rem; font-size: 0.92rem; margin-top: 0.5rem; color: var(--ink); } +.votebar-for.w-0, .votebar-against.w-0 { display: none; } +.votebar-legend b { font-weight: 600; } +.votebar-legend .pct { color: var(--muted); margin-left: 0.3rem; white-space: nowrap; } +.votefig-slim { margin-top: 0.6rem; } +.votefig-slim .votebar { height: 4px; } +.votefig-slim .votebar-legend { font-size: 0.88rem; color: var(--muted); margin-top: 0.4rem; } +.votefig-slim .votebar-legend b { color: var(--ink); } +.topic-card-mine { font-size: 0.88rem; color: var(--accent); margin: 0.35rem 0 0; } +.similar-hint { background: var(--field); border-radius: var(--radius-sm); padding: 0.6rem 0.75rem; font-size: 0.9rem; } +.similar-hint p { margin: 0 0 0.35rem; } +.similar-hint .plain-list { display: flex; flex-direction: column; gap: 0.3rem; } +.similar { margin-top: 1rem; } +.similar .plain-list { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.95rem; } +.vote-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.9rem; } +.vote-actions .btn { flex: 1 1 8rem; } +.vote-btn { background: var(--field); color: var(--ink); } +.vote-btn.is-active { background: var(--accent); color: var(--accent-ink); } +.vote-btn.is-locked { background: var(--btn-locked); color: var(--btn-locked-ink); } +.vote-btn.is-dim { background: var(--btn-dim); color: var(--btn-dim-ink); } +.vote-btn[disabled] { cursor: default; opacity: 1; } +.topic-tools { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin-top: 0.8rem; } +.topic-tools .btn, .topic-tools .link-quiet { + font-size: 0.9rem; padding: 0.4rem 0.8rem; border-radius: 999px; + background: var(--field); color: var(--accent); font-weight: 600; +} +.topic-tools .link-quiet { color: var(--danger); } +.topic-tools .link-quiet:hover, .topic-tools .btn:hover { text-decoration: none; opacity: 0.85; } +.link-quiet { color: var(--muted); font-size: 0.92rem; } +.ico { width: 1.15rem; height: 1.15rem; display: block; } +.card-h { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 0 0 0.5rem; } +.check-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.check-list li { display: flex; align-items: baseline; gap: 0.6rem; font-size: 0.95rem; } +.check-list li > span { flex: none; width: 1.1rem; text-align: center; font-weight: 700; } +.check-list .is-ok > span { color: var(--vote-for); } +.check-list .is-warn { color: var(--muted); } +.check-list .is-warn > span { color: var(--danger); } +.fav-menu { position: relative; } +.fav-menu > summary { + list-style: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; + width: 2.15rem; height: 2.15rem; border-radius: 999px; background: var(--field); color: var(--accent); +} +.fav-menu > summary::-webkit-details-marker { display: none; } +.fav-menu > summary::marker { content: ''; } +.fav-menu[open] > summary { background: var(--accent-soft); } +.fav-pop { + position: absolute; z-index: 60; bottom: calc(100% + 0.4rem); left: 0; min-width: 12rem; + background: var(--surface); border-radius: var(--radius-sm); padding: 0.3rem; + box-shadow: 0 10px 34px rgba(0,0,0,0.20); display: flex; flex-direction: column; +} +.fav-item { + display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; width: 100%; + background: none; border: 0; font: inherit; font-size: 1rem; color: var(--ink); cursor: pointer; + padding: 0.55rem 0.7rem; border-radius: 8px; text-align: left; +} +.fav-item:hover { background: var(--field); } +.fav-item.is-on { color: var(--accent); font-weight: 600; } + +.form-stack { display: flex; flex-direction: column; gap: 0.85rem; } +.form-stack > label { display: flex; flex-direction: column; gap: 0.3rem; font-weight: 600; font-size: 0.92rem; } +.form-stack small { font-weight: 400; } +.form-row { display: grid; grid-template-columns: 1fr; gap: 0.85rem; } +.form-row label { display: flex; flex-direction: column; gap: 0.3rem; font-weight: 600; font-size: 0.92rem; } +input[type="text"], input[type="search"], input[type="date"], input[type="number"], textarea, select { + font: inherit; font-size: 1.0625rem; color: var(--ink); background: var(--field); + border: 0; border-radius: var(--radius-sm); padding: 0.7rem 0.8rem; width: 100%; + -webkit-appearance: none; appearance: none; +} +select { background-image: none; } +textarea { resize: vertical; } +input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 0; } +.check-label { display: flex; align-items: flex-start; gap: 0.6rem; font-weight: 400; font-size: 1rem; } +.check-label input { margin-top: 0.28rem; accent-color: var(--accent); width: 1.1rem; height: 1.1rem; } +.criteria-set, .end-fields { border: 0; background: var(--field); border-radius: var(--radius-sm); padding: 0.8rem 0.9rem; display: flex; flex-direction: column; gap: 0.6rem; margin: 0; } +.criteria-set legend, .end-fields legend { font-weight: 600; padding: 0; font-size: 0.92rem; float: left; width: 100%; margin-bottom: 0.2rem; } +.criteria-set input[type="radio"] { accent-color: var(--accent); width: 1.1rem; height: 1.1rem; margin-top: 0.25rem; } +.end-fields input, .end-fields select { background: var(--surface); } +.end-fields .check { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 400; } +.end-fields .check input { accent-color: var(--accent); width: 1.15rem; height: 1.15rem; flex: none; } +.end-row { display: flex; gap: 0.5rem; margin: -0.2rem 0 0.2rem 1.75rem; } +.end-row input, .end-row select { flex: 1 1 0; min-width: 0; width: auto; } +.end-row input[type="number"] { flex: 0 1 7.5rem; } +.end-row[hidden] { display: none; } +.scope-picker { display: flex; flex-direction: column; gap: 0.4rem; } +.law-quote { font-size: 0.92rem; color: var(--muted); display: block; margin-top: 0.25rem; } +.hr-soft { border: 0; border-top: 1px solid var(--sep); margin: 1rem 0 0.6rem; } + +.auth-card { max-width: 24rem; margin: 1.6rem auto; text-align: center; padding: 1.4rem 1.2rem 1.2rem; } +.auth-card h1 { font-size: 1.4rem; } +.auth-action { display: flex; justify-content: center; margin: 0.9rem 0 0.2rem; } +.auth-action .btn, .provider-list .btn { width: 100%; } +.provider-list { display: flex; flex-direction: column; gap: 0.55rem; margin: 1rem 0 0.2rem; } +.provider-btn { width: 100%; } +.tap-icon { width: 6rem; height: auto; color: var(--accent); margin: 0.2rem auto 0.6rem; display: block; } +.tap-status { font-weight: 600; color: var(--accent); } + +.start-gate { min-height: 82vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.9rem; padding: 2rem 1.2rem; } +.start-brand { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin: 0; } +.start-langs { display: flex; gap: 0.8rem; margin-top: 1rem; flex-wrap: wrap; justify-content: center; } +.start-langs form { display: flex; } +.lang-btn { + display: flex; flex-direction: column; align-items: center; gap: 0.5rem; + background: var(--surface); border: 0; border-radius: var(--radius); + padding: 0.9rem 1.2rem; font: inherit; font-weight: 600; color: var(--ink); cursor: pointer; +} +.lang-btn:active { opacity: 0.7; } +.flag { width: 4.2rem; height: auto; display: block; border-radius: 4px; } + +.error-card { max-width: 26rem; margin: 3rem auto; text-align: center; } +.prose { max-width: 40rem; } +.prose p { color: var(--muted); } +.countdown { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: 0.4rem; } + +.site-footer { border-top: 1px solid var(--sep); background: var(--surface); font-size: 0.85rem; color: var(--muted); } +.footer-inner { display: flex; gap: 0.5rem 1.2rem; flex-wrap: wrap; padding-top: 0.9rem; padding-bottom: 0.9rem; } +.footer-nav { display: flex; gap: 1rem; } +.footer-nav a { color: var(--muted); } + +.modal { position: fixed; inset: 0; z-index: 200; display: none; } +.modal:target { display: flex; align-items: flex-end; justify-content: center; } +.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.4); } +.modal-box { + position: relative; z-index: 1; background: var(--page); + border-radius: 16px 16px 0 0; width: 100%; max-height: 90vh; overflow-y: auto; + padding: 0.5rem 1rem 1.6rem; +} +.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.6rem 0 0.7rem; position: sticky; top: 0; background: var(--page); } +.modal-head h2 { margin: 0; font-size: 1.15rem; } +.modal-close { font-size: 1.6rem; line-height: 1; color: var(--muted); padding: 0 0.3rem; } +.modal-close:hover { color: var(--ink); text-decoration: none; } +.modal-body > .form-stack, .modal-body > form { background: var(--surface); border-radius: var(--radius); padding: 0.95rem 1rem; } + +@media (min-width: 40rem) { + .modal:target { align-items: center; } + .modal-box { width: min(34rem, 92vw); border-radius: var(--radius); max-height: 86vh; } + .form-row { grid-template-columns: 1fr 1fr; } + .topic-grid { grid-template-columns: 1fr 1fr; } + .auth-action .btn, .provider-list .btn { width: auto; min-width: 16rem; } + .provider-list { align-items: center; } + .auth-action { justify-content: center; } +} +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } +.path-set input[type="radio"] { float: left; margin: .25rem .5rem 0 0; } +.path-label { display: block; overflow: hidden; margin: 0 0 .4rem; } +.only-eid, .only-list { display: none; clear: both; } +#path-eid:checked ~ .only-eid, #path-list:checked ~ .only-list { display: flex; flex-direction: column; gap: .6rem; } +.form-stack > .check-label { display: flex; flex-direction: row; align-items: center; gap: .5rem; } +.check-list li { display: flex; gap: .5rem; } +.btn:disabled { opacity: .5; } +.consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 300; background: var(--surface); border-top: 1px solid var(--sep); padding: 0.7rem 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.6rem; font-size: 0.9rem; } +.consent-actions { display: flex; gap: 0.4rem; } +.site-footer { margin-bottom: 4.5rem; } +CSS; + +const SW_JS = <<<'JS' + +(function () { + 'use strict'; + var init = function () { + + var nodes = document.querySelectorAll('[data-countdown-to]'); + if (nodes.length > 0) { + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + var update = function () { + nodes.forEach(function (node) { + var target = Date.parse(node.getAttribute('data-countdown-to').replace(' ', 'T') + 'Z'); + var diff = Math.max(0, Math.floor((target - Date.now()) / 1000)); + var text = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60); + node.textContent = (node.getAttribute('data-label') || '') + ' ' + text; + }); + }; + update(); + setInterval(update, 1000); + } + + document.querySelectorAll('select[data-scope-native]').forEach(function (native) { + var current = native.value; + var level = 'de', land = '', kreis = ''; + if (current.indexOf('bl:') === 0) { level = 'bundesland'; land = current.slice(3); } + else if (current.indexOf('kr:') === 0) { level = 'landkreis'; var r = current.slice(3).split(':'); land = r[0]; kreis = r.slice(1).join(':'); } + else if (current === '') { level = native.querySelector('option[value=""]') ? 'all' : 'de'; } + var lands = {}, order = []; + native.querySelectorAll('optgroup').forEach(function (g) { + var name = g.label; order.push(name); lands[name] = []; + g.querySelectorAll('option').forEach(function (o) { + if (o.value.indexOf('kr:') === 0) { lands[name].push(o.textContent); } + }); + }); + var L = { + de: native.getAttribute('data-l-de') || 'DE', + bl: native.getAttribute('data-l-bl') || 'Bundesland', + kr: native.getAttribute('data-l-kr') || 'Landkreis', + pick: native.getAttribute('data-l-pick') || '—', + all: native.getAttribute('data-l-all') || '—' + }; + var wrap = document.createElement('div'); + wrap.className = 'scope-picker'; + var hasAll = !!native.querySelector('option[value=""]'); + var selLevel = document.createElement('select'); + if (hasAll) { selLevel.add(new Option(L.all, 'all')); } + selLevel.add(new Option(L.de, 'de')); + selLevel.add(new Option(L.bl, 'bundesland')); + selLevel.add(new Option(L.kr, 'landkreis')); + selLevel.value = level; + var selLand = document.createElement('select'); + selLand.add(new Option(L.pick, '')); + order.forEach(function (n) { selLand.add(new Option(n, n)); }); + if (land) { selLand.value = land; } + var selKreis = document.createElement('select'); + var fillKreis = function () { + selKreis.innerHTML = ''; + selKreis.add(new Option(L.pick, '')); + (lands[selLand.value] || []).forEach(function (k) { selKreis.add(new Option(k, k)); }); + if (kreis) { selKreis.value = kreis; } + }; + fillKreis(); + var sync = function () { + var v = 'de'; + if (selLevel.value === 'all') { v = ''; } + else if (selLevel.value === 'de') { v = 'de'; } + else if (selLevel.value === 'bundesland') { v = selLand.value ? 'bl:' + selLand.value : ''; } + else if (selLevel.value === 'landkreis') { v = (selLand.value && selKreis.value) ? 'kr:' + selLand.value + ':' + selKreis.value : ''; } + native.value = v; + selLand.hidden = !(selLevel.value === 'bundesland' || selLevel.value === 'landkreis'); + selKreis.hidden = selLevel.value !== 'landkreis'; + }; + selLevel.addEventListener('change', sync); + selLand.addEventListener('change', function () { kreis = ''; fillKreis(); sync(); }); + selKreis.addEventListener('change', sync); + native.style.display = 'none'; + native.parentNode.insertBefore(wrap, native); + wrap.appendChild(selLevel); wrap.appendChild(selLand); wrap.appendChild(selKreis); + sync(); + }); + + document.querySelectorAll('[data-end-fields]').forEach(function (fs) { + fs.querySelectorAll('[data-end-toggle]').forEach(function (box) { + var part = fs.querySelector('[data-end-part="' + box.getAttribute('data-end-toggle') + '"]'); + if (!part) return; + var apply = function () { part.hidden = !box.checked; }; + box.addEventListener('change', apply); apply(); + }); + }); + + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && location.hash && document.querySelector(location.hash + '.modal')) { + location.hash = ''; + } + }); + + var base = document.body.getAttribute('data-base') || ''; + var figures = document.querySelectorAll('[data-topic]'); + if (figures.length > 0) { + var ids = []; + figures.forEach(function (el) { ids.push(el.getAttribute('data-topic')); }); + var groupSep = document.body.getAttribute('data-group-sep') || ''; + var groupNum = function (n) { + return groupSep === '' ? String(n) : String(n).replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + }; + var setNum = function (el, sel, value) { + var node = el.querySelector(sel); + if (node && node.textContent !== value) { node.textContent = value; } + }; + var refresh = function () { + fetch(base + '/api/topics?ids=' + encodeURIComponent(ids.join(',')), { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + if (!data) { return; } + figures.forEach(function (el) { + var row = data[el.getAttribute('data-topic')]; + if (!row) { return; } + var total = row.f + row.a; + var pf = total > 0 ? Math.round(row.f * 100 / total) : 0; + var pa = total > 0 ? 100 - pf : 0; + var bf = el.querySelector('[data-bar="for"]'); + var ba = el.querySelector('[data-bar="against"]'); + if (bf) { bf.className = 'votebar-for w-' + pf; } + if (ba) { ba.className = 'votebar-against w-' + pa; } + setNum(el, '[data-num="for"]', groupNum(row.f)); + setNum(el, '[data-num="against"]', groupNum(row.a)); + setNum(el, '[data-pct="for"]', '/ ' + pf + ' %'); + setNum(el, '[data-pct="against"]', '/ ' + pa + ' %'); + }); + }) + .catch(function () {}); + }; + var timer = setInterval(refresh, 12000); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') { refresh(); } + }); + window.addEventListener('pagehide', function () { clearInterval(timer); }); + } + + document.querySelectorAll('[data-similar-for]').forEach(function (box) { + var input = document.querySelector(box.getAttribute('data-similar-for')); + if (!input) { return; } + var exclude = box.getAttribute('data-similar-not') || ''; + var wait = null; + var lookup = function () { + var q = input.value.trim(); + if (q.length < 6) { box.hidden = true; box.innerHTML = ''; return; } + fetch(base + '/api/similar?q=' + encodeURIComponent(q) + (exclude ? '¬=' + encodeURIComponent(exclude) : ''), + { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (rows) { + if (!rows || rows.length === 0) { box.hidden = true; box.innerHTML = ''; return; } + var list = document.createElement('ul'); + list.className = 'plain-list'; + rows.forEach(function (row) { + var li = document.createElement('li'); + var a = document.createElement('a'); + a.href = base + '/topic/' + row.id; + a.textContent = row.title; + li.appendChild(a); + list.appendChild(li); + }); + var head = document.createElement('p'); + head.className = 'muted'; + head.textContent = box.getAttribute('data-similar-label') || ''; + box.innerHTML = ''; + box.appendChild(head); + box.appendChild(list); + box.hidden = false; + }) + .catch(function () {}); + }; + input.addEventListener('input', function () { + clearTimeout(wait); + wait = setTimeout(lookup, 400); + }); + }); + + var profileUrl = document.body.getAttribute('data-profile-url'); + if (profileUrl) { + fetch(profileUrl, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : null; }) + .then(function (text) { + try { + if (text) { sessionStorage.setItem('profil.yaml', text); } + } catch (e) {} + }) + .catch(function () {}); + } + var logoutForms = document.querySelectorAll('form.js-logout'); + logoutForms.forEach(function (form) { + form.addEventListener('submit', function () { + try { sessionStorage.removeItem('profil.yaml'); } catch (e) {} + }); + }); + + var tapForm = document.getElementById('tap-form'); + if (tapForm && 'NDEFReader' in window) { + var status = document.getElementById('tap-status'); + var done = false; + var go = function () { + if (!done) { done = true; tapForm.submit(); } + }; + var startScan = function () { + var reader = new NDEFReader(); + reader.addEventListener('reading', go); + reader.addEventListener('readingerror', go); + return reader.scan(); + }; + + try { + startScan().then(function () { + + if (status) { status.hidden = false; } + tapForm.querySelectorAll('[data-nfc-hide]').forEach(function (b) { b.hidden = true; }); + }).catch(function () { }); + } catch (e) { } + + tapForm.addEventListener('submit', function (ev) { + if (tapForm.getAttribute('data-armed') === '1') { return; } + ev.preventDefault(); + tapForm.setAttribute('data-armed', '1'); + if (status) { status.hidden = false; } + try { + startScan().catch(go); + } catch (e) { go(); } + }); + } + }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } + else { init(); } +})(); +JS; + +const SW_ICON = <<<'SVG' + + + + + +SVG; + +function flag_de(): string +{ + return ''; +} + +function flag_en(): string +{ + return ''; +} + +function icon_links(): string +{ + return '' + . '' + . ''; +} + +function png_chunk(string $type, string $data): string +{ + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); +} + +function icon_png(int $size): string +{ + $ss = 3; + $n = $size * $ss; + $bg = [17, 17, 17]; + $fg = [255, 255, 255]; + $a1 = [0.265 * $n, 0.515 * $n]; + $b1 = [0.430 * $n, 0.680 * $n]; + $a2 = [0.430 * $n, 0.680 * $n]; + $b2 = [0.735 * $n, 0.340 * $n]; + $half = 0.058 * $n; + + $inBar = static function (float $px, float $py, array $a, array $b, float $half): bool { + $vx = $b[0] - $a[0]; + $vy = $b[1] - $a[1]; + $len = sqrt($vx * $vx + $vy * $vy); + if ($len <= 0.0) { + return false; + } + $ux = $vx / $len; + $uy = $vy / $len; + $wx = $px - $a[0]; + $wy = $py - $a[1]; + $along = $wx * $ux + $wy * $uy; + $perp = abs($wx * -$uy + $wy * $ux); + return $perp <= $half && $along >= -$half && $along <= $len + $half; + }; + + $raw = ''; + for ($y = 0; $y < $size; $y++) { + $raw .= "\x00"; + for ($x = 0; $x < $size; $x++) { + $rSum = 0; $gSum = 0; $bSum = 0; + for ($sy = 0; $sy < $ss; $sy++) { + for ($sx = 0; $sx < $ss; $sx++) { + $px = $x * $ss + $sx + 0.5; + $py = $y * $ss + $sy + 0.5; + $on = $inBar($px, $py, $a1, $b1, $half) || $inBar($px, $py, $a2, $b2, $half); + $col = $on ? $fg : $bg; + $rSum += $col[0]; $gSum += $col[1]; $bSum += $col[2]; + } + } + $total = $ss * $ss; + $raw .= chr((int) round($rSum / $total)) . chr((int) round($gSum / $total)) + . chr((int) round($bSum / $total)) . chr(255); + } + } + $ihdr = pack('NN', $size, $size) . chr(8) . chr(6) . chr(0) . chr(0) . chr(0); + return "\x89PNG\r\n\x1a\n" + . png_chunk('IHDR', $ihdr) + . png_chunk('IDAT', gzcompress($raw, 9)) + . png_chunk('IEND', ''); +} + +function icon_ico(int $size = 32): string +{ + $png = icon_png($size); + $dim = $size >= 256 ? 0 : $size; + return pack('vvv', 0, 1, 1) + . chr($dim) . chr($dim) . chr(0) . chr(0) + . pack('vv', 1, 32) + . pack('VV', strlen($png), 22) + . $png; +} + +function serve_asset(string $kind): void +{ + $map = [ + 'css' => ['text/css; charset=utf-8', SW_CSS], + 'js' => ['text/javascript; charset=utf-8', SW_JS], + 'icon' => ['image/svg+xml', SW_ICON], + ]; + if (!isset($map[$kind])) { + http_response_code(404); + exit; + } + header('Content-Type: ' . $map[$kind][0]); + header('Cache-Control: public, max-age=3600'); + header('X-Content-Type-Options: nosniff'); + echo $map[$kind][1]; + if ($kind === 'css') { + + for ($i = 0; $i <= 100; $i++) { + echo "\n.w-" . $i . ' { flex-grow: ' . $i . '; }'; + } + } + exit; +} + +function render(string $title, string $content, int $status = 200): void +{ + http_response_code($status); + echo v_layout($title, $content); + exit; +} + +function v_layout(string $title, string $content): string +{ + $cfg = SW::$cfg; + $user = auth_user(); + $duty = $user === null ? null : jury_pending_for((int) $user['id']); + $query = (string) ($_SERVER['QUERY_STRING'] ?? ''); + $returnValue = SW::$path . ($query !== '' ? '?' . $query : ''); + $b = base_path(); + $a = SW::$base; + + $html = '' + . '' + . '' + . '' . e($title) . ' · ' . e((string) $cfg['app_name']) . '' + . '' + . icon_links() + . '' + . ''; + if (!empty($cfg['show_test_banner'])) { + $html .= '
' . e(t('banner.test')) . '
'; + } + $html .= '' + . '' + ; + + $flashes = take_flashes(); + if ($flashes !== []) { + $html .= '
'; + foreach ($flashes as $f) { + $html .= '
' + . e(t((string) $f['key'], (array) $f['repl'])) . '
'; + } + $html .= '
'; + } + $html .= '
' . $content . '
' + . '' + . (consent_given() ? '' : '') + . ''; + return $html; +} + +function scope_text(array $row): string +{ + $name = (string) ($row['scope_name'] ?? ''); + return $name !== '' ? $name : t('scope.bund'); +} + +function p_topic_card(array $row): string +{ + $html = '
' + . '' . e(cat_name($row)) . '' + . '' . e(scope_text($row)) . '
' + . '

' . e((string) $row['title']) . '

' + . '

' . e((string) $row['goal']) . '

' + . p_votebar((int) $row['votes_for'], (int) $row['votes_against'], true); + if (!empty($row['my_choice'])) { + $html .= '

' + . e(t('topic.your_vote', ['choice' => t($row['my_choice'] === 'for' ? 'vote.for' : 'vote.against')])) . '

'; + } + return $html . '
'; +} + +function p_vote_button(string $choice, ?string $myVote): string +{ + $mine = $myVote === $choice; + return ''; +} + +function p_vote_locked(?string $myVote): string +{ + $html = '
'; + foreach (['for', 'against'] as $choice) { + $mine = $myVote === $choice; + $state = $myVote === null ? ' is-dim' : ($mine ? ' is-locked' : ' is-dim'); + $html .= ''; + } + return $html . '
'; +} + +function p_votebar(int $for, int $against, bool $slim = false): string +{ + $total = $for + $against; + $pctFor = $total > 0 ? (int) round($for * 100 / $total) : 0; + $pctAgainst = $total > 0 ? 100 - $pctFor : 0; + $cls = $slim ? 'votefig votefig-slim' : 'votefig'; + return '
' + . '
' + . '' . e(t('vote.for')) + . ' ' . e(num($for)) . '' + . '/ ' . $pctFor . ' %' + . '' . e(t('vote.against')) + . ' ' . e(num($against)) . '' + . '/ ' . $pctAgainst . ' %' + . '
'; +} + +function topic_end_fields(array $old): string +{ + $byDate = (bool) ($old['end_by_date'] ?? true); + $byTarget = (bool) ($old['end_by_target'] ?? false); + if (!$byDate && !$byTarget) { + $byDate = true; + } + $defaultDate = substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10); + $date = ($old['end_date'] ?? '') !== '' ? (string) $old['end_date'] : $defaultDate; + $value = ($old['end_value'] ?? '') !== '' ? (string) $old['end_value'] : ''; + $unit = ($old['end_unit'] ?? '') === 'percent' ? 'percent' : 'count'; + $minDate = substr(Clock::addDaysStr(Clock::nowStr(), 1), 0, 10); + $maxDate = substr(Clock::addDaysStr(Clock::nowStr(), 365), 0, 10); + return '
' . e(t('topic.f_end')) . '' + . '' + . '
' + . '' + . '
' + . '' + . '
' + . '' + . '
' + . '
'; +} + +function topic_form_html(array $errors, array $old, string $action, string $submitKey, ?int $selfId = null): string +{ + $formId = $selfId === null ? 'new' : ('e' . $selfId); + $html = ''; + if ($errors !== []) { + $html .= ''; + } + $html .= '
' . csrf_field() + . '' + . '' + . '' + . '' + . '
' + . '
' + . topic_end_fields($old) + . '
'; + return $html; +} + +function modal(string $id, string $title, string $inner): string +{ + return ''; +} + +function v_main(array $formErrors = [], ?array $formOld = null): void +{ + $user = auth_user(); + $userId = $user === null ? null : (int) $user['id']; + $html = ''; + + $upcoming = $userId === null ? null : jury_upcoming_for($userId); + if ($upcoming !== null) { + $html .= '
' . e(t('me.jury_upcoming', ['date' => Clock::displayLocal((string) $upcoming['voting_starts_at'], t('common.date_format'))])) . '
'; + } + + $scopeValue = query_str('gebiet', 160); + $scopeDecoded = $scopeValue === '' ? null : scope_decode($scopeValue); + if ($scopeDecoded === null) { + $scopeValue = ''; + } + $filters = [ + 'category' => query_str('category', 64), + 'level' => $scopeDecoded === null ? '' : $scopeDecoded[0], + 'scope' => $scopeDecoded === null || $scopeDecoded[1] === null ? '' : $scopeDecoded[1], + 'gebiet' => $scopeValue, + 'q' => query_str('q', 80), + 'sort' => in_array(query_str('sort', 10), ['new', 'top'], true) ? query_str('sort', 10) : 'net', + ]; + $page = query_int('page', 1, 500, 1); + $perPage = (int) SW::$cfg['page_size']; + $result = topics_list($filters, $page, $perPage, $userId); + $pages = max(1, (int) ceil($result['total'] / $perPage)); + + $html .= '
'; + if ($userId !== null) { + $html .= '' . e(t('topic.new_title')) . ''; + } + $html .= '' . e(t('topics.search')) . ''; + if ($filters['q'] !== '' || $filters['category'] !== '' || $filters['gebiet'] !== '') { + $html .= '' . e(t('topics.clear')) . ''; + } + $html .= '
'; + + $chips = ''; + foreach ($userId === null ? [] : fav_list($userId) as $favorite) { + if ($favorite['kind'] === 'topic') { + if (($favorite['topic_title'] ?? null) === null) { + continue; + } + $label = (string) $favorite['topic_title']; + if (mb_strlen($label) > 34) { + $label = mb_substr($label, 0, 33) . '…'; + } + $href = url('/topic/' . (int) $favorite['ref']); + } elseif ($favorite['kind'] === 'category') { + $label = SW::$lang === 'de' ? (string) ($favorite['name_de'] ?? $favorite['ref']) : (string) ($favorite['name_en'] ?? $favorite['ref']); + $href = url('/') . '?category=' . rawurlencode((string) $favorite['ref']); + } else { + $gebiet = fav_to_gebiet((string) $favorite['ref']); + if ($gebiet === null) { + continue; + } + $parts = explode(':', (string) $favorite['ref'], 2); + $label = $parts[0] === 'bund' || !isset($parts[1]) ? t('scope.bund') : $parts[1]; + $href = url('/') . '?gebiet=' . rawurlencode($gebiet); + } + $chips .= '' . icon_bookmark(true) . e($label) . ''; + } + if ($chips !== '') { + $html .= '
' . $chips . '
'; + } + + $recent = []; + foreach ($userId === null ? [] : topics_voted_by($userId) as $row) { + if (!in_array((string) $row['status'], ['removed', 'archived'], true) && empty($row['locked'])) { + $recent[] = $row; + } + } + if ($recent !== []) { + $html .= '

' . e(t('home.recent_votes')) . '

'; + } + + if ($result['rows'] === []) { + $html .= '

' . e(t('topics.none')) . '

'; + } else { + $html .= '
'; + foreach ($result['rows'] as $row) { + $html .= p_topic_card($row); + } + $html .= '
'; + } + if ($pages > 1) { + $mkQuery = static function (int $p) use ($filters): string { + $keep = ['category' => $filters['category'], 'gebiet' => $filters['gebiet'], + 'q' => $filters['q'], 'sort' => $filters['sort'], 'page' => $p]; + $params = array_filter($keep, static function ($v) { + return $v !== '' && $v !== null; + }); + return $params === [] ? '' : '?' . http_build_query($params); + }; + $html .= ''; + } + + if ($userId !== null) { + $old = $formOld ?? ['title' => '', 'goal' => '', 'reasoning' => '', 'category_id' => 0, 'scope' => 'de', + 'end_by_date' => true, 'end_date' => '', 'end_by_target' => false, + 'end_value' => '', 'end_unit' => 'count']; + if (topic_has_posted_today($userId)) { + $newInner = '

' . e(t('topic.posted_today')) + . '

'; + } else { + $newInner = topic_form_html($formErrors, $old, '/topics', 'topic.submit'); + } + $html .= modal('modal-new', t('topic.new_title'), $newInner); + } + + $searchInner = '
' + . '' + . '' + . '' + . '' + . '
'; + $html .= modal('modal-search', t('topics.search'), $searchInner); + + render(t('app.tagline'), $html); +} + +function topic_end_text(array $topic): string +{ + if ($topic['status'] === 'archived') { + return t('topic.archived_badge'); + } + if ($topic['status'] === 'closed') { + return t('topic.ended'); + } + $date = $topic['end_date'] !== null + ? Clock::displayLocal((string) $topic['end_date'] . ' 00:00:00', t('common.date_format')) + : null; + $target = $topic['end_target'] !== null ? (int) $topic['end_target'] : null; + $have = num((int) $topic['votes_for'] + (int) $topic['votes_against']); + if ($date !== null && $target !== null) { + return t('topic.ends_both', ['date' => $date, 'have' => $have, 'target' => num($target)]); + } + if ($date !== null) { + return t('topic.ends_on', ['date' => $date]); + } + if ($target !== null) { + return t('topic.ends_count', ['have' => $have, 'target' => num($target)]); + } + return ''; +} + +function fav_menu(int $userId, int $topicId, string $catRef, string $catLabel, string $scopeRef, string $scopeLabel): string +{ + $back = '/topic/' . $topicId; + $items = [ + ['topic', (string) $topicId, t('topic.this'), fav_is($userId, 'topic', (string) $topicId)], + ['category', $catRef, $catLabel, fav_is($userId, 'category', $catRef)], + ['scope', $scopeRef, $scopeLabel, fav_is($userId, 'scope', $scopeRef)], + ]; + $any = false; + foreach ($items as $item) { + $any = $any || $item[3]; + } + $html = '
' + . icon_bookmark($any) . '
'; + foreach ($items as [$kind, $ref, $label, $on]) { + $html .= '
' . csrf_field() + . '' + . '' + . '' + . '
'; + } + return $html . '
'; +} + +function icon_bookmark(bool $filled): string +{ + return ''; +} + +function v_topic(int $id): void +{ + $topic = topic_find($id); + if ($topic === null) { + v_error_404(); + } + topic_close_if_due($topic); + $topic = topic_find($id); + if ($topic['status'] === 'removed') { + $html = '

' . e(t('topic.removed_title')) . '

' + . '

' . e(t('topic.removed_text')) . '

' + . '

' . e(t('common.back_home')) . '

'; + render(t('topic.removed_title'), $html); + } + $user = auth_user(); + $userId = $user === null ? null : (int) $user['id']; + $voteRow = $userId === null ? null : topic_user_vote_row($id, $userId); + $myVote = $voteRow === null ? null : (string) $voteRow['choice']; + $isAuthor = $userId !== null && (int) $topic['author_id'] === $userId; + $openReport = report_open_for($id); + $archived = $topic['status'] === 'archived'; + $closed = $topic['status'] !== 'active'; + $scopeRef = $topic['scope_level'] === 'bund' ? 'bund' : $topic['scope_level'] . ':' . (string) $topic['scope_name']; + + $html = '
' + . '' . e(cat_name($topic)) . '' + . '' . e(scope_text($topic)) . '' + . ($archived ? '' . e(t('topic.archived_badge')) . '' + : ($closed ? '' . e(t('topic.ended')) . '' : '')) + . '
' + . '

' . e((string) $topic['title']) . '

' + . '

' . e(Clock::displayLocal((string) $topic['created_at'], t('common.date_format'))) + . ' · ' . e(topic_end_text($topic)) . '

' + . '

' . e(t('topic.goal_label')) . '

' + . '

' . nl2br(e((string) $topic['goal'])) . '

' + . '

' . e(t('topic.reasoning_label')) . '

' + . '

' . nl2br(e((string) $topic['reasoning'])) . '

'; + + $barFor = (int) $topic['votes_for']; + $barAgainst = (int) $topic['votes_against']; + $html .= '
' . p_votebar($barFor, $barAgainst); + if ($archived) { + $html .= '

' . e(t('topic.archived_note')) . '

'; + } elseif ($user === null) { + $html .= '

' . e(t('vote.login_hint')) . '

'; + } elseif ($closed || ($voteRow !== null && $voteRow['locked'])) { + $html .= p_vote_locked($myVote); + } else { + $html .= '
' . csrf_field() + . '' + . p_vote_button('for', $myVote) . p_vote_button('against', $myVote); + if ($myVote !== null) { + $html .= ''; + } + $html .= '
'; + } + $html .= '
'; + if ($user !== null) { + $html .= fav_menu( + (int) $user['id'], + (int) $topic['id'], + (string) $topic['category_slug'], + cat_name($topic), + $scopeRef, + scope_text($topic) + ); + } + $locked = topic_has_votes((int) $topic['id']); + if ($isAuthor && !$archived) { + $html .= '' . e(t('topic.edit')) . ''; + if (!$locked) { + $html .= '' . e(t('topic.archive')) . ''; + } + } + if ($openReport !== null) { + $html .= '' . e(t('topic.report_open')) . ''; + } elseif ($user !== null && !$isAuthor && !$closed) { + $html .= '' . e(t('topic.report_link')) . ''; + } + $html .= '
'; + $similar = topics_similar((string) $topic['title'], (int) $topic['id'], 4); + if ($similar !== []) { + $html .= '

' . e(t('topic.similar')) . '

'; + } + $html .= '
'; + + if ($isAuthor && !$locked && !$archived) { + $delInner = '

' . e(t('topic.archive_confirm')) . '

' + . '
' . csrf_field() + . '
' + . '' . e(t('common.close')) . '
'; + $html .= modal('modal-del', t('topic.archive'), $delInner); + } + render((string) $topic['title'], $html); +} + +function v_topic_edit(int $id, array $errors = [], ?array $old = null): void +{ + $user = require_user(); + $topic = topic_find($id); + if ($topic === null) { + v_error_404(); + } + if ((int) $topic['author_id'] !== (int) $user['id'] + || in_array((string) $topic['status'], ['removed', 'archived'], true)) { + flash('error', 'flash.not_author'); + redirect('/topic/' . $id); + } + if ($old === null) { + $scopeVal = $topic['scope_level'] === 'bund' ? 'de' + : ($topic['scope_level'] === 'bundesland' ? 'bl:' . $topic['scope_name'] : 'kr:'); + + if ($topic['scope_level'] === 'landkreis') { + foreach (SW_REGIONS as $land => $kreise) { + if (in_array((string) $topic['scope_name'], $kreise, true)) { + $scopeVal = 'kr:' . $land . ':' . $topic['scope_name']; + break; + } + } + } + $old = [ + 'title' => (string) $topic['title'], + 'goal' => (string) $topic['goal'], + 'reasoning' => (string) $topic['reasoning'], + 'category_id' => (int) $topic['category_id'], + 'scope' => $scopeVal, + 'end_by_date' => $topic['end_date'] !== null, + 'end_date' => (string) ($topic['end_date'] ?? ''), + 'end_by_target' => $topic['end_target'] !== null, + 'end_value' => (string) ($topic['end_target'] ?? ''), + 'end_unit' => 'count', + ]; + } + $html = '

' . e(t('topic.edit')) . '

' + . '
' . topic_form_html($errors, $old, '/topic/' . $id . '/edit', 'topic.save', $id) + . '

' . e(t('report.cancel')) . '

'; + render(t('topic.edit'), $html); +} + +function h_api_topics(): void +{ + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + header('X-Content-Type-Options: nosniff'); + $ids = []; + foreach (explode(',', query_str('ids', 400)) as $raw) { + if (preg_match('/^\d{1,10}$/', trim($raw)) === 1) { + $ids[] = (int) $raw; + } + } + $ids = array_slice(array_unique($ids), 0, 50); + if ($ids === []) { + echo '{}'; + exit; + } + $marks = implode(',', array_fill(0, count($ids), '?')); + $rows = SW::$db->all( + "SELECT t.id, t.status, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against + FROM topics t WHERE t.id IN ($marks)", + $ids + ); + $out = []; + foreach ($rows as $row) { + $out[(string) $row['id']] = [ + 'f' => (int) $row['votes_for'], + 'a' => (int) $row['votes_against'], + 's' => (string) $row['status'], + ]; + } + echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function h_api_similar(): void +{ + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + header('X-Content-Type-Options: nosniff'); + if (auth_user() === null) { + http_response_code(401); + echo '[]'; + exit; + } + $title = query_str('q', SW_TITLE_MAX); + $exclude = query_str('not', 10); + $out = []; + foreach (topics_similar($title, $exclude === '' ? null : (int) $exclude, 4) as $row) { + $out[] = ['id' => (int) $row['id'], 'title' => (string) $row['title']]; + } + echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function v_setup(array $errors = [], ?array $old = null): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + $old = $old ?? [ + 'eid_mode' => (string) SW::$cfg['eid_mode'], + 'eid_server_url' => (string) SW::$cfg['eid_server_url'], + 'eid_server_cert' => (string) SW::$cfg['eid_server_cert'], + 'eid_server_key' => (string) SW::$cfg['eid_server_key'], + 'eid_client_url' => (string) SW::$cfg['eid_client_url'], + 'authorized_keys_url' => (string) SW::$cfg['authorized_keys_url'], + 'nect_start' => (string) (SW::$cfg['eid_providers']['nect']['start'] ?? ''), + ]; + $html = '

' . e(t('setup.title')) . '

' + . '

' . e(t('setup.checks')) . '

'; + + if ($errors !== []) { + $html .= ''; + } + + $eid = $old['eid_mode'] === 'eid'; + $html .= '
' . csrf_field() + . '
' + . '

' . e(t('setup.path')) . '

' + . '' + . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '' + . '
' + . '
' + . '' + . '
' + . '
' + . '
'; + if ((string) SW::$cfg['testmode_end'] === 'gui') { + $html .= '
' + . '' + . '' . e(t('setup.token_hint')) . '' + . '' + . '
'; + } else { + $html .= '

' . e(t('setup.end_locked')) . '

'; + } + $html .= '
'; + render(t('setup.title'), $html); +} + +function h_setup_check(): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + if (!rate_allow('setup:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/setup'); + } + [$errors, $values] = setup_validate(setup_read_post()); + $errors = array_values(array_filter($errors, static function (string $key): bool { + return $key !== 'setup.err_list_empty'; + })); + if ($errors !== []) { + v_setup($errors, $values); + } + unset($_SESSION['setup_check']); + if ($values['eid_mode'] === 'eid') { + if ($values['eid_server_url'] === '') { + flash('info', 'flash.setup_no_server'); + v_setup([], $values); + } + $ok = setup_probe($values); + flash($ok ? 'success' : 'error', $ok ? 'flash.setup_ok' : 'flash.setup_failed'); + } else { + $count = setup_probe_list((string) $values['authorized_keys_url']); + $ok = $count !== null && $count > 0; + if ($count === null) { + flash('error', 'flash.setup_list_failed'); + } elseif ($count === 0) { + flash('error', 'setup.err_list_empty'); + } else { + flash('success', 'flash.setup_list_ok', ['n' => num($count)]); + } + } + if ($ok) { + $_SESSION['setup_check'] = setup_check_stamp($values); + } + v_setup([], $values); +} + +function h_setup_finish(): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + if ((string) SW::$cfg['testmode_end'] !== 'gui') { + redirect('/setup'); + } + if (!rate_allow('setup:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/setup'); + } + [$errors, $values] = setup_validate(setup_read_post()); + if (post_str('confirm', 10) !== 'yes') { + $errors[] = 'flash.testmode_confirm'; + } + $token = setup_token(); + if ($token === '' || !hash_equals($token, post_str('token', 80))) { + log_line('SECURITY', 'setup_token_bad', []); + $errors[] = 'setup.err_token'; + } + if (!setup_checked($values)) { + $errors[] = 'setup.err_check_first'; + } + if ($errors !== []) { + v_setup(array_values(array_unique($errors)), $values); + } + if (!setup_save($values)) { + v_setup(['setup.err_save'], $values); + } + setup_apply($values); + test_mode_end(); + unset($_SESSION['setup_check']); + @unlink(setup_token_file()); + auth_logout(); + card_forget(); + flash('info', 'flash.testmode_ended'); + redirect('/auth'); +} + +function v_auth(): void +{ + if (auth_user() !== null) { + redirect('/me'); + } + + $pictogram = ''; + $mode = (string) SW::$cfg['eid_mode']; + $card = card_load(); + $ready = $mode !== 'eid' && $card !== null && authorized_contains(card_identity($card)); + + $html = '
' . $pictogram + . '

' . e(t('auth.title')) . '

'; + + if (test_mode()) { + + $html .= '
' . csrf_field() + . '
'; + } else { + + $html .= '
'; + foreach ((array) SW::$cfg['eid_providers'] as $key => $prov) { + $html .= '' + . e(t('auth.with', ['app' => (string) $prov['label']])) . ''; + } + $html .= '
'; + if ($ready) { + + $html .= '
' + . '
' . csrf_field() + . '
' + . ''; + } + } + $html .= '
'; + render(t('auth.title'), $html); +} + +function site_url(string $path = ''): string +{ + $scheme = sw_is_https() ? 'https' : 'http'; + $host = (string) ($_SERVER['HTTP_HOST'] ?? SW::$cfg['domain']); + return $scheme . '://' . $host . base_path() . $path; +} + +function h_eid_start(): void +{ + $key = query_str('provider', 30); + $providers = (array) SW::$cfg['eid_providers']; + if (!isset($providers[$key])) { + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + if (!rate_allow('eid:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/auth'); + } + if ($key === 'ausweisapp') { + + $nonce = bin2hex(random_bytes(16)); + eid_flow_start($nonce); + $tcToken = site_url('/eid/tctoken?s=' . $nonce); + $client = (string) SW::$cfg['eid_client_url']; + log_line('SECURITY', 'eid_client_activation', ['provider' => 'ausweisapp']); + header('Location: ' . $client . '?tcTokenURL=' . rawurlencode($tcToken), true, 303); + exit; + } + $start = (string) ($providers[$key]['start'] ?? ''); + if ($start === '' || preg_match('#^https://#', $start) !== 1) { + + flash('error', 'flash.eid_provider_off'); + redirect('/auth'); + } + + $sep = strpos($start, '?') === false ? '?' : '&'; + header('Location: ' . $start . $sep . 'redirect=' . rawurlencode(site_url('/eid/callback')), true, 303); + exit; +} + +function h_eid_tctoken(): void +{ + header('Content-Type: text/xml; charset=utf-8'); + header('Cache-Control: no-store'); + $nonce = query_str('s', 40); + $errorUrl = site_url('/eid/callback?e=1'); + $flow = eid_flow_find($nonce); + if ($flow === null) { + echo '' . "\n" + . '' . e($errorUrl) + . ''; + exit; + } + $session = eid_server_useid(); + if ($session === null) { + + log_line('SECURITY', 'eid_tctoken_unconfigured', []); + echo '' . "\n" + . '' . e($errorUrl) + . ''; + exit; + } + eid_flow_bind($nonce, $session['session']); + echo '' . "\n" + . '' + . '' . e($session['paos']) . '' + . '' . e($session['session']) . '' + . '' . e(site_url('/eid/callback')) . '' + . '' . e($errorUrl) . '' + . 'urn:liberty:paos:2006-08' + . ''; + exit; +} + +function eid_flow_start(string $nonce): void +{ + SW::$db->run('DELETE FROM eid_flows WHERE created_at < ?', [Clock::now()->getTimestamp() - 600]); + SW::$db->run( + 'INSERT INTO eid_flows (nonce, session_id, created_at) VALUES (?, ?, ?)', + [$nonce, session_id(), Clock::now()->getTimestamp()] + ); +} + +function eid_flow_find(string $nonce): ?array +{ + if (preg_match('/^[a-f0-9]{32}$/', $nonce) !== 1) { + return null; + } + $row = SW::$db->one('SELECT * FROM eid_flows WHERE nonce = ?', [$nonce]); + if ($row === null || (int) $row['created_at'] < Clock::now()->getTimestamp() - 600) { + return null; + } + return $row; +} + +function eid_flow_bind(string $nonce, string $ref): void +{ + SW::$db->run('UPDATE eid_flows SET eid_ref = ? WHERE nonce = ?', [$ref, $nonce]); +} + +function eid_server_useid(?array $over = null): ?array +{ + $cfg = $over ?? SW::$cfg; + $url = (string) ($cfg['eid_server_url'] ?? ''); + if ($url === '' || preg_match('#^https://#', $url) !== 1) { + return null; + } + $soap = '' + . '' + . '' + . ''; + $ctx = ['http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/xml; charset=utf-8\r\nSOAPAction: \"\"\r\n", + 'content' => $soap, + 'timeout' => 8, + 'ignore_errors' => true, + ], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]; + if ((string) ($cfg['eid_server_cert'] ?? '') !== '') { + $ctx['ssl']['local_cert'] = (string) $cfg['eid_server_cert']; + } + if ((string) ($cfg['eid_server_key'] ?? '') !== '') { + $ctx['ssl']['local_pk'] = (string) $cfg['eid_server_key']; + } + $xml = @file_get_contents($url, false, stream_context_create($ctx)); + if (!is_string($xml) || $xml === '') { + log_line('SECURITY', 'eid_server_unreachable', []); + return null; + } + $paos = eid_xml_value($xml, 'eCardServerAddress'); + $session = eid_xml_value($xml, 'Session'); + if ($session === '') { + $session = eid_xml_value($xml, 'ID'); + } + if ($paos === '' || $session === '') { + log_line('SECURITY', 'eid_server_bad_response', []); + return null; + } + return ['paos' => $paos, 'session' => $session]; +} + +function eid_xml_value(string $xml, string $name): string +{ + $pattern = '#<(?:[A-Za-z0-9_.-]+:)?' . preg_quote($name, '#') . '(?:\s[^>]*)?>([^<]*)one( + 'SELECT * FROM eid_flows WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', + [session_id()] + ); + SW::$db->run('DELETE FROM eid_flows WHERE session_id = ?', [session_id()]); + if ($flow === null || ($flow['eid_ref'] ?? '') === '' || query_str('e', 4) !== '') { + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + + log_line('SECURITY', 'eid_callback_unverified', []); + flash('error', 'flash.eid_required'); + redirect('/auth'); +} + +function v_start(): void +{ + http_response_code(200); + $banner = empty(SW::$cfg['show_test_banner']) + ? '' + : '
' . e(SW_DE['banner.test']) . ' / ' . e(SW_EN['banner.test']) . '
'; + echo '' + . '' + . '' + . '' . e((string) SW::$cfg['app_name']) . '' + . '' + . icon_links() + . '' . $banner + . '
' + . '

' . e((string) SW::$cfg['app_name']) . '

' + . '
' + . '
' . csrf_field() + . '' + . '
' + . '
' . csrf_field() + . '' + . '
' + . '
'; + exit; +} + +function v_error_404(): void +{ + $html = '

' . e(t('error.not_found_title')) . '

' + . '

' . e(t('error.not_found')) . '

' + . '

' . e(t('common.back_home')) . '

'; + render(t('error.not_found_title'), $html, 404); +} + +function v_error(int $status, string $messageKey): void +{ + $html = '

' . e(t('error.generic_title')) . '

' + . '

' . e(t($messageKey)) . '

' + . '

' . e(t('common.back_home')) . '

'; + render(t('error.generic_title'), $html, $status); +} + +function v_static(string $titleKey, array $paraKeys): void +{ + $html = '

' . e(t($titleKey)) . '

'; + foreach ($paraKeys as $key) { + $html .= '

' . nl2br(e(t($key)), false) . '

'; + } + $html .= '
'; + render(t($titleKey), $html); +} + +function yq(string $v): string +{ + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $v) . '"'; +} + +function profile_yaml(array $user): string +{ + $userId = (int) $user['id']; + $y = "buergerabstimmung_profil:\n"; + $y .= " oeffentlicher_schluessel: " . yq((string) $user['pseudonym_hash']) . "\n"; + $duty = jury_pending_for($userId); + $upcoming = $duty === null ? jury_upcoming_for($userId) : null; + $y .= " jury_aufgabe: " . yq($duty !== null ? 'offen' : ($upcoming !== null ? 'ausgelost' : 'keine')) . "\n"; + $y .= " stimmen:\n"; + $voted = topics_voted_by($userId); + if ($voted === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($voted as $row) { + $y .= " - thema: " . (int) $row['id'] . "\n"; + $y .= " titel: " . yq((string) $row['title']) . "\n"; + $y .= " stimme: " . yq($row['my_choice'] === 'for' ? 'dafuer' : 'dagegen') . "\n"; + } + $y .= " eigene_themen:\n"; + $authored = topics_by_author($userId); + if ($authored === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($authored as $row) { + $y .= " - thema: " . (int) $row['id'] . "\n"; + $y .= " titel: " . yq((string) $row['title']) . "\n"; + $y .= " status: " . yq((string) $row['status']) . "\n"; + } + $y .= " favoriten:\n"; + $favorites = fav_list($userId); + if ($favorites === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($favorites as $favorite) { + $art = ['category' => 'kategorie', 'topic' => 'thema'][$favorite['kind']] ?? 'gebiet'; + $y .= " - art: " . yq($art) . "\n"; + $y .= " wert: " . yq((string) $favorite['ref']) . "\n"; + } + $y .= " meldungen:\n"; + $reports = reports_by($userId); + if ($reports === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($reports as $report) { + $y .= " - thema: " . (int) $report['topic_id'] . "\n"; + $y .= " status: " . yq((string) $report['status']) . "\n"; + } + return $y; +} + +function server_sign_pk_hex(): string +{ + if (!card_supports_sodium() || SW::$serverSign === '') { + return ''; + } + return bin2hex(sodium_crypto_sign_publickey_from_secretkey(SW::$serverSign)); +} + +function profile_sealed(array $user): string +{ + $plain = profile_yaml($user); + $pkHex = (string) $user['pseudonym_hash']; + if (!card_supports_sodium() || SW::$serverSign === '' || strlen(@hex2bin($pkHex) ?: '') !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + + $sig = sw_hmac('profil|' . $plain); + return $plain . "# integritaet(hmac-sha256): " . $sig . "\n"; + } + $edPk = hex2bin($pkHex); + $curvePk = sodium_crypto_sign_ed25519_pk_to_curve25519($edPk); + $cipher = sodium_crypto_box_seal($plain, $curvePk); + $sig = sodium_crypto_sign_detached($cipher, SW::$serverSign); + $out = "buergerabstimmung_versiegeltes_profil:\n"; + $out .= " hinweis: " . yq('An oeffentlichen Ausweis-Schluessel verschluesselt; nur mit dem Ausweis lesbar.') . "\n"; + $out .= " verschluesselt_fuer: " . yq($pkHex) . "\n"; + $out .= " server_schluessel: " . yq(server_sign_pk_hex()) . "\n"; + $out .= " chiffre_b64: " . yq(base64_encode($cipher)) . "\n"; + $out .= " server_signatur_b64: " . yq(base64_encode($sig)) . "\n"; + return $out; +} + +function v_jury(): void +{ + $user = require_user(); + $userId = (int) $user['id']; + $duty = jury_pending_for($userId); + $upcoming = $duty === null ? jury_upcoming_for($userId) : null; + + $html = '

' . e(t('jury.title')) . '

'; + if ($duty === null && $upcoming === null) { + $html .= '

' . e(t('jury.none')) . '

' + . '

' . e(t('nav.topics')) . '

'; + render(t('jury.title'), $html); + } + if ($duty === null && $upcoming !== null) { + $html .= '
' . e(t('jury.upcoming', ['date' => Clock::displayLocal((string) $upcoming['voting_starts_at'], t('common.date_format'))])) . '
' + . '

' . e(t('nav.topics')) . '

'; + render(t('jury.title'), $html); + } + + $law = SW_LAWS[(string) $duty['criteria']] ?? null; + $tally = jury_tally((int) $duty['id']); + $deadline = jury_deadline($duty); + + $html .= '

' . e(t('jury.intro')) . '

' + . '
' . e(t('jury.blocked')) . '
' + . '

' . e(t('jury.reported')) . '

' + . '

' . e((string) $duty['title']) . '

' + . '

' . e(t('topic.goal_label')) . '

' . nl2br(e((string) $duty['goal'])) . '

' + . '

' . e(t('topic.reasoning_label')) . '

' . nl2br(e((string) $duty['reasoning'])) . '

' + . '

' . e(t('jury.law')) . '

'; + if ($law !== null) { + $html .= '

' . e($law['norm']) . ' – ' . e($law['titel']) . '

' + . '

„' . e($law['text']) . '“

'; + } else { + $html .= '

' . e((string) $duty['criteria']) . '

'; + } + $html .= '

' . e(t('jury.question')) . '

' + . '
' . csrf_field() + . '' + . '' + . '' + . '' + . '
' + . '

' . e(t('jury.stats', [ + 'cast' => num($tally['cast']), + 'seats' => num($tally['seats']), + 'quorum' => num(min((int) $duty['quorum'], $tally['seats'])), + ])) . '

' + . '

' . e(t('jury.deadline', ['date' => Clock::displayLocal($deadline, t('common.datetime_format'))])) . '

' + . '
'; + render(t('jury.title'), $html); +} + +function v_report(int $topicId): void +{ + require_user(); + $topic = topic_find($topicId); + if ($topic === null || $topic['status'] !== 'active') { + v_error_404(); + } + if (report_open_for($topicId) !== null) { + flash('info', 'flash.report_already_open'); + redirect('/topic/' . $topicId); + } + $q = query_str('q', 80); + $hits = law_search($q); + $html = '

' . e(t('report.title')) . '

' + . '

' . e(t('report.intro')) . '

' + . '

' . e(t('jury.reported')) . '

' + . '

' . e((string) $topic['title']) . '

' + . '
' + . '' + . '
' + . '
'; + if ($q !== '' && $hits === []) { + $html .= '

' . e(t('report.none_found')) . '

'; + } + if ($hits !== []) { + $html .= '
' . csrf_field() + . '' + . '
' . e(t('report.pick')) . ''; + foreach ($hits as $id => $law) { + $html .= ''; + } + $html .= '
' + . '

' . e(t('report.process')) . '

' + . '
' + . '' . e(t('report.cancel')) . '
' + . '
'; + } + render(t('report.title'), $html); +} + +function safe_return(string $fallback): string +{ + $return = post_str('return', 200); + if (preg_match('#^/(topics(\?[A-Za-z0-9=&%._\-]*)?|topic/\d{1,10}|topics/new|me|jury|auth|imprint|privacy)?$#', $return) === 1) { + return $return === '' ? '/' : $return; + } + return $fallback; +} + +function h_tap(): void +{ + if (!rate_allow('auth:' . ip_key(), 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/auth'); + } + if (test_mode()) { + + $card = card_create(); + authorized_add([card_identity($card)], 'test-login'); + test_key_add(card_identity($card)); + } else { + + if ((string) SW::$cfg['eid_mode'] === 'eid') { + log_line('SECURITY', 'eid_not_configured', []); + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + + $card = card_load(); + if ($card === null) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + } + + $identity = card_identity($card); + $sealed = card_seal($card, 'login:' . $identity); + if (!card_open($card['pk'], $sealed, 'login:' . $identity)) { + log_line('SECURITY', 'card_verify_failed', []); + flash('error', 'flash.auth_failed'); + redirect('/auth'); + } + + if (!authorized_contains($identity)) { + log_line('SECURITY', 'card_not_authorized', []); + card_forget(); + flash('error', 'flash.card_not_authorized'); + redirect('/auth'); + } + auth_login($identity); + $_SESSION['auth_slot'] = time_slot(); + redirect('/'); +} + +function h_claim(string $handle): void +{ + if ((string) SW::$cfg['eid_mode'] === 'eid') { + redirect('/auth'); + } + if (preg_match('/^[a-f0-9]{16,64}$/', $handle) !== 1) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $file = SW::$dataDir . '/issued/' . $handle . '.key'; + if (!is_file($file)) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $secret = base64_decode(trim((string) file_get_contents($file)), true); + if ($secret === false) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $_SESSION['card'] = base64_encode($secret); + flash('info', 'flash.card_ready'); + redirect('/auth'); +} + +function h_card_new(): void +{ + auth_logout(); + card_forget(); + flash('info', 'flash.card_new'); + redirect('/auth'); +} + +function h_logout(): void +{ + auth_logout(); + flash('info', 'flash.logged_out'); + redirect('/'); +} + +function h_lang(): void +{ + $lang = post_str('lang', 5); + if (!in_array($lang, (array) SW::$cfg['langs'], true)) { + redirect('/'); + } + $_SESSION['lang'] = $lang; + redirect(safe_return('/')); +} + +function h_vote(): void +{ + $user = require_user(); + require_card($user); + $topicId = post_int('topic_id'); + $choice = post_str('choice', 10); + if ($topicId === null) { + redirect('/topics'); + } + $back = '/topic/' . $topicId; + if (!rate_allow('vote:' . (int) $user['id'], 60, 600)) { + flash('error', 'flash.rate_limited'); + redirect($back); + } + try { + vote_cast((int) $user['id'], $topicId, $choice); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect($back); + } + flash('success', $choice === 'none' ? 'flash.vote_withdrawn' : 'flash.vote_saved'); + redirect($back); +} + +function h_topic_create(): void +{ + $user = require_user(); + require_card($user); + $userId = (int) $user['id']; + if (!rate_allow('topic-form:' . $userId, 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/topics/new'); + } + [$errors, $old, $scope, $end] = topic_form_read(); + if ($errors !== []) { + v_main($errors, $old); + } + try { + $topicId = topic_create( + $userId, + $old['title'], + $old['goal'], + $old['reasoning'], + (int) $old['category_id'], + $scope[0], + $scope[1], + $end[0], + $end[1], + $end[2] + ); + } catch (DomainException $e) { + v_main([$e->getMessage()], $old); + return; + } + flash('success', 'flash.topic_created'); + redirect('/topic/' . $topicId); +} + +function topic_form_read(): array +{ + $old = [ + 'title' => post_str('title', SW_TITLE_MAX), + 'goal' => post_str('goal', SW_GOAL_MAX, true), + 'reasoning' => post_str('reasoning', SW_REASONING_MAX, true), + 'category_id' => post_int('category_id') ?? 0, + 'scope' => post_str('scope', 160), + 'end_by_date' => isset($_POST['end_by_date']), + 'end_date' => post_str('end_date', 10), + 'end_by_target' => isset($_POST['end_by_target']), + 'end_value' => post_str('end_value', 10), + 'end_unit' => post_str('end_unit', 10), + ]; + $errors = []; + if (mb_strlen($old['title']) < SW_TITLE_MIN) { + $errors[] = 'topic.err_title'; + } + if (mb_strlen($old['goal']) < SW_GOAL_MIN) { + $errors[] = 'topic.err_goal'; + } + if (mb_strlen($old['reasoning']) < SW_REASONING_MIN) { + $errors[] = 'topic.err_reasoning'; + } + $category = $old['category_id'] > 0 + ? SW::$db->one('SELECT id FROM categories WHERE id = ?', [$old['category_id']]) + : null; + if ($category === null) { + $errors[] = 'topic.err_category'; + } + $scope = scope_decode($old['scope']); + if ($scope === null) { + $errors[] = 'topic.err_scope'; + } + $end = parse_topic_end(); + if ($end === null) { + $errors[] = 'topic.err_end'; + } + return [$errors, $old, $scope, $end]; +} + +function h_topic_edit(int $topicId): void +{ + $user = require_user(); + require_card($user); + [$errors, $old, $scope, $end] = topic_form_read(); + if ($errors !== []) { + v_topic_edit($topicId, $errors, $old); + } + try { + topic_update( + $topicId, + (int) $user['id'], + $old['title'], + $old['goal'], + $old['reasoning'], + (int) $old['category_id'], + $scope[0], + $scope[1], + $end[0], + $end[1], + $end[2] + ); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/topic/' . $topicId); + } + flash('success', 'flash.topic_updated'); + redirect('/topic/' . $topicId); +} + +function h_topic_archive(int $topicId): void +{ + $user = require_user(); + require_card($user); + try { + topic_archive($topicId, (int) $user['id']); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/topic/' . $topicId); + } + flash('success', 'flash.topic_archived'); + redirect('/topic/' . $topicId); +} + +function h_favorite(): void +{ + $user = require_user(); + require_card($user); + $kind = post_str('kind', 10); + $ref = post_str('ref', 100); + $back = safe_return('/topics'); + if (!rate_allow('favorite:' . (int) $user['id'], 60, 600)) { + flash('error', 'flash.rate_limited'); + redirect($back); + } + try { + $added = fav_toggle((int) $user['id'], $kind, $ref); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect($back); + } + flash('success', $added ? 'flash.favorite_added' : 'flash.favorite_removed'); + redirect($back); +} + +function h_report_create(): void +{ + $user = require_user(); + require_card($user); + $topicId = post_int('topic_id'); + if ($topicId === null) { + redirect('/topics'); + } + if (!rate_allow('report-form:' . (int) $user['id'], 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/report/' . $topicId); + } + $lawId = post_str('law', 40); + if (!isset(SW_LAWS[$lawId])) { + flash('error', 'flash.report_no_law'); + redirect('/report/' . $topicId); + } + try { + report_create($topicId, (int) $user['id'], $lawId); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/topic/' . $topicId); + } + log_line('SECURITY', 'report_created', ['topic' => $topicId]); + flash('success', 'flash.report_created'); + redirect('/topic/' . $topicId); +} + +function h_jury_vote(): void +{ + $user = require_user(); + require_card($user); + $reportId = post_int('report_id'); + $vote = post_str('vote', 10); + if ($reportId === null) { + redirect('/jury'); + } + if (!rate_allow('jury:' . (int) $user['id'], 30, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/jury'); + } + try { + jury_cast($reportId, (int) $user['id'], $vote); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/jury'); + } + flash('success', 'flash.jury_voted'); + redirect('/jury'); +} + +function send_security_headers(): void +{ + header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; " + . "img-src 'self'; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: no-referrer'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()'); + header('Cross-Origin-Opener-Policy: same-origin'); + header('Cross-Origin-Resource-Policy: same-origin'); + header('X-Robots-Tag: noindex'); + if (sw_is_https()) { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } +} + +function web_main(): void +{ + try { + sw_setup(); + } catch (Throwable $e) { + error_log('buergerabstimmung setup: ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: text/html; charset=utf-8'); + $writable = is_writable(__DIR__ . '/data') || (!is_dir(__DIR__ . '/data') && is_writable(__DIR__)); + $hint = $writable + ? 'Bitte prüfen Sie, ob die PHP-Erweiterungen pdo_sqlite und mbstring aktiv sind.' + : 'Bitte machen Sie das Verzeichnis für PHP beschreibbar (per FTP: Rechte 755 oder 775 für den Ordner der index.php setzen) und laden Sie die Seite neu.'; + echo '' + . 'Einrichtung erforderlich' + . '

Fast geschafft

Die Anwendung konnte noch nicht starten.

' . $hint . '

' + . '

Details stehen im Server-Fehlerprotokoll.

'; + exit; + } + + $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'))); + $base = rtrim($scriptDir, '/'); + if ($base !== '' && preg_match('#^(/[A-Za-z0-9._~\-]+)+$#', $base) !== 1) { + $base = ''; + } + SW::$base = $base; + + $rawPath = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/'); + if (preg_match('#(^|/)data(/|$)#', $rawPath) === 1) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + echo "404\n"; + exit; + } + $pathInfo = (string) ($_SERVER['PATH_INFO'] ?? ''); + if ($pathInfo !== '' && $pathInfo[0] === '/') { + $path = $pathInfo; + } else { + $path = $rawPath; + if (SW::$base !== '' && strpos($path, SW::$base) === 0) { + $path = substr($path, strlen(SW::$base)); + } + if (strpos($path, '/index.php') === 0) { + $path = substr($path, strlen('/index.php')); + } + } + SW::$path = $path === '' ? '/' : $path; + $path = SW::$path; + + $method0 = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if (($method0 === 'GET' || $method0 === 'HEAD') && strpos($rawPath, '/index.php') !== false) { + $qs = (string) ($_SERVER['QUERY_STRING'] ?? ''); + header('Location: ' . (base_path() . $path) . ($qs !== '' ? '?' . $qs : ''), true, 301); + exit; + } + + if (preg_match('#^/a/(app\.css|app\.js|icon\.svg)$#', $path, $m) === 1) { + $kindMap = ['app.css' => 'css', 'app.js' => 'js', 'icon.svg' => 'icon']; + serve_asset($kindMap[$m[1]]); + } + if ($path === '/robots.txt') { + header('Content-Type: text/plain; charset=utf-8'); + echo "User-agent: *\nDisallow: /\n"; + exit; + } + + if (preg_match('#^/(favicon\.ico|favicon\.png|apple-touch-icon(?:-precomposed)?\.png|icon-192\.png)$#', $path, $mIcon) === 1) { + $name = $mIcon[1]; + header('Cache-Control: public, max-age=86400'); + header('X-Content-Type-Options: nosniff'); + if ($name === 'favicon.ico') { + header('Content-Type: image/x-icon'); + echo icon_ico(32); + } else { + $size = $name === 'favicon.png' ? 32 : ($name === 'icon-192.png' ? 192 : 180); + header('Content-Type: image/png'); + echo icon_png($size); + } + exit; + } + + if ($path === '/server.pub') { + header('Content-Type: text/plain; charset=utf-8'); + echo server_sign_pk_hex() . "\n"; + exit; + } + + send_security_headers(); + session_boot(); + + $user = auth_user(); + $lang = is_string($_SESSION['lang'] ?? null) ? (string) $_SESSION['lang'] : ''; + if ($lang === '' && !consent_given()) { + $lang = lang_from_browser(); + } + if (!in_array($lang, (array) SW::$cfg['langs'], true)) { + $lang = (string) SW::$cfg['default_lang']; + } + SW::$lang = $lang; + SW::$tActive = $lang === 'en' ? SW_EN : SW_DE; + + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + try { + maintenance_tick_throttled(); + + if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) { + v_error(405, 'error.method'); + } + + $reading = $method === 'GET' || $method === 'HEAD'; + if ($path === '/consent' && $reading) { + consent_set(); + redirect(consent_return()); + } + + $public = $path === '/' || preg_match('#^/topic/\d{1,10}$#', $path) === 1 + || in_array($path, ['/imprint', '/privacy', '/api/topics'], true); + if (!consent_given() && !($reading && $public)) { + v_error(403, 'error.consent'); + } + + $langChosen = !consent_given() || is_string($_SESSION['lang'] ?? null) || $user !== null; + if (!$langChosen && $reading + && !in_array($path, ['/start', '/imprint', '/privacy'], true) + && strpos($path, '/eid/') !== 0 + && strpos($path, '/api/') !== 0) { + redirect('/start'); + } + if ($path === '/start' && $reading) { + if ($langChosen) { + redirect('/'); + } + v_start(); + } + + if ($method === 'POST' && !csrf_ok()) { + log_line('SECURITY', 'csrf_failed', ['path' => $path]); + flash('error', 'flash.csrf'); + redirect('/'); + } + + if ($user !== null && jury_pending_for((int) $user['id']) !== null) { + $gateAllowed = ['/jury', '/jury/vote', '/logout', '/lang', '/imprint', '/privacy']; + if (!in_array($path, $gateAllowed, true)) { + redirect('/jury'); + } + } + + $isGet = $method === 'GET' || $method === 'HEAD'; + + if ($user === null && $isGet + && !in_array($path, ['/', '/auth', '/imprint', '/privacy', '/api/topics', '/api/similar'], true) + && preg_match('#^/topic/\d{1,10}$#', $path) !== 1 + && strpos($path, '/claim/') !== 0 + && strpos($path, '/eid/') !== 0) { + redirect('/auth'); + } + + if ($path === '/' && $isGet) { + v_main(); + } + if (($path === '/topics' || $path === '/topics/new' || $path === '/me') && $isGet) { + redirect('/'); + } + if ($path === '/topics' && $method === 'POST') { + h_topic_create(); + } + if (preg_match('#^/topic/(\d{1,10})$#', $path, $m) === 1 && $isGet) { + v_topic((int) $m[1]); + } + if (preg_match('#^/topic/(\d{1,10})/edit$#', $path, $m) === 1 && $isGet) { + v_topic_edit((int) $m[1]); + } + if (preg_match('#^/topic/(\d{1,10})/edit$#', $path, $m) === 1 && $method === 'POST') { + h_topic_edit((int) $m[1]); + } + if (preg_match('#^/topic/(\d{1,10})/archive$#', $path, $m) === 1 && $method === 'POST') { + h_topic_archive((int) $m[1]); + } + if ($path === '/vote' && $method === 'POST') { + h_vote(); + } + if ($path === '/favorite' && $method === 'POST') { + h_favorite(); + } + if (preg_match('#^/report/(\d{1,10})$#', $path, $m) === 1 && $isGet) { + v_report((int) $m[1]); + } + if ($path === '/report' && $method === 'POST') { + h_report_create(); + } + if ($path === '/jury' && $isGet) { + v_jury(); + } + if ($path === '/jury/vote' && $method === 'POST') { + h_jury_vote(); + } + if ($path === '/profil.yaml' && $isGet) { + $u = require_user(); + header('Content-Type: text/yaml; charset=utf-8'); + header('Content-Disposition: attachment; filename="profil.yaml"'); + echo profile_sealed($u); + exit; + } + + if ($path === '/lang' && $method === 'POST') { + h_lang(); + } + if ($path === '/auth' && $isGet) { + v_auth(); + } + if (preg_match('#^/claim/([a-f0-9]{16,64})$#', $path, $m) === 1 && $isGet) { + h_claim($m[1]); + } + if ($path === '/eid/start' && $isGet) { + h_eid_start(); + } + if ($path === '/eid/tctoken' && $isGet) { + h_eid_tctoken(); + } + if ($path === '/eid/callback' && $isGet) { + h_eid_callback(); + } + if ($path === '/tap' && $method === 'POST') { + h_tap(); + } + if ($path === '/card/new' && $method === 'POST') { + h_card_new(); + } + if ($path === '/logout' && $method === 'POST') { + h_logout(); + } + if ($path === '/api/topics' && $isGet) { + h_api_topics(); + } + if ($path === '/api/similar' && $isGet) { + h_api_similar(); + } + if ($path === '/setup' && $isGet) { + v_setup(); + } + if ($path === '/setup/check' && $method === 'POST') { + h_setup_check(); + } + if ($path === '/setup/finish' && $method === 'POST') { + h_setup_finish(); + } + if ($path === '/imprint' && $isGet) { + v_static('imprint.h', ['imprint.p1', 'imprint.p2', 'imprint.p3']); + } + if ($path === '/privacy' && $isGet) { + v_static('privacy.h', ['privacy.p1', 'privacy.p2', 'privacy.p3', 'privacy.p4', 'privacy.p5', + 'privacy.p6', 'privacy.p7', 'privacy.p8', 'privacy.p9', 'privacy.p10']); + } + v_error_404(); + } catch (Throwable $e) { + log_line('ERROR', 'unhandled', ['type' => get_class($e), 'msg' => $e->getMessage(), 'path' => $path]); + v_error(500, 'error.generic'); + } +} + +function cli_switch_db(string $tmpDir, string $name): void +{ + putenv('BUERGERABSTIMMUNG_DB=' . $tmpDir . '/' . $name . '.sqlite'); + SW::$db = new Db($tmpDir . '/' . $name . '.sqlite'); + SW::$db->migrate(); + sw_seed_categories(); +} + +function cli_add_users(int $count, string $prefix): array +{ + $ids = []; + for ($i = 1; $i <= $count; $i++) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, created_at) VALUES (?, ?, ?)', + [$prefix . '-' . $i, 'de', Clock::nowStr()] + ); + $ids[] = SW::$db->lastId(); + } + return $ids; +} + +function cli_make_topic(int $authorId, string $title): int +{ + $categoryId = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $endDate = substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10); + return topic_create($authorId, $title, 'Ein Ziel für den Selbsttest dieses Themas.', 'Eine Begründung für den Selbsttest dieses Themas.', $categoryId, 'bund', null, 'date', $endDate, null); +} + +function cli_selftest(): int +{ + $tmpDir = sys_get_temp_dir() . '/buergerabstimmung-selftest-' . bin2hex(random_bytes(4)); + mkdir($tmpDir, 0700, true); + $pass = 0; + $fail = 0; + $check = static function (string $description, bool $ok, string $note = '') use (&$pass, &$fail): void { + if ($ok) { + $pass++; + echo " ok {$description}\n"; + } else { + $fail++; + echo "FAIL {$description}" . ($note !== '' ? ': ' . $note : '') . "\n"; + } + }; + SW::$pepper = bin2hex(random_bytes(32)); + SW::$dataDir = $tmpDir; + if (card_supports_sodium()) { + SW::$serverSign = sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair()); + } + $t0 = new DateTimeImmutable('2026-03-02 12:00:00', new DateTimeZone('Europe/Berlin')); + Clock::setTestNow($t0); + $warp = static function (string $modify) use (&$t0): void { + $t0 = $t0->modify($modify); + Clock::setTestNow($t0); + }; + + echo "== Grunddaten ==\n"; + cli_switch_db($tmpDir, 'base'); + $check('Kategorien angelegt', count(categories()) >= 20); + $check('Keine vorbefüllten Themen', site_stats()['topics'] === 0); + $check('System-Konto vorhanden', (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 1') === 1); + + echo "== Ausweis-Schlüssel & Zeitfenster ==\n"; + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $card = ['secret' => sodium_crypto_sign_secretkey($pair), 'pk' => sodium_crypto_sign_publickey($pair)]; + $other = sodium_crypto_sign_keypair(); + $otherPk = sodium_crypto_sign_publickey($other); + } else { + $secret = random_bytes(32); + $card = ['secret' => $secret, 'pk' => hash('sha256', 'pk|' . $secret, true)]; + $otherPk = hash('sha256', 'pk|' . random_bytes(32), true); + } + $sealed = card_seal($card, 'vote'); + $check('Umschlag öffnet mit richtigem öffentlichen Schlüssel', card_open($card['pk'], $sealed, 'vote') === true); + $check('Fremder öffentlicher Schlüssel wird abgelehnt', card_open($otherPk, $sealed, 'vote') === false); + $check('Falsche Aktion wird abgelehnt', card_open($card['pk'], $sealed, 'report') === false); + $tSave = $t0; + $warp('+11 minutes'); + $check('Alter Umschlag verfällt (TOTP-Zeitfenster)', card_open($card['pk'], $sealed, 'vote') === false); + $check('Neuer Umschlag zu neuer Zeit ist anders und gültig', + card_seal($card, 'vote') !== $sealed && card_open($card['pk'], card_seal($card, 'vote'), 'vote') === true); + $t0 = $tSave; + Clock::setTestNow($t0); + $check('Identität = öffentlicher Schlüssel (kein Pseudonym)', card_identity($card) === bin2hex($card['pk'])); + + echo "== Stimmen entkoppelt & 24h-Sperre ==\n"; + $vt1 = cli_add_users(1, 'vt')[0]; + $vtTopic = cli_make_topic($vt1, 'Thema für Stimmtests'); + vote_cast($vt1, $vtTopic, 'for'); + $check('Stimmen-Tabelle ohne Ausweis-Bezug (kein user_id)', + SW::$db->val("SELECT COUNT(*) FROM pragma_table_info('votes') WHERE name = 'user_id'") == 0); + $tag = vote_tag($vtTopic, user_pk($vt1)); + $check('Stimme unter HMAC-Marker gespeichert (nicht Klar-ID)', + SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ? AND voter_tag = ?', [$vtTopic, $tag]) == 1); + vote_cast($vt1, $vtTopic, 'against'); + $check('Änderung innerhalb 24 h möglich', topic_user_vote($vtTopic, $vt1) === 'against'); + $warp('+25 hours'); + try { + vote_cast($vt1, $vtTopic, 'for'); + $check('Änderung nach 24 h gesperrt', false); + } catch (DomainException $e) { + $check('Änderung nach 24 h gesperrt', $e->getMessage() === 'flash.vote_locked'); + } + $warp('-25 hours'); + + echo "== Themen-Ende (Datum/Anzahl) & Autor-Rechte ==\n"; + $au = cli_add_users(1, 'au')[0]; + $catId = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $countTopic = topic_create($au, 'Thema endet bei 2 Stimmen', 'Ziel für den Anzahl-Test hier.', 'Begründung für den Anzahl-Test hier.', $catId, 'bund', null, 'count', null, 2); + $c1 = cli_add_users(1, 'c1')[0]; + $c2 = cli_add_users(1, 'c2')[0]; + vote_cast($c1, $countTopic, 'for'); + $check('Thema bei Zielzahl noch offen (1/2)', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$countTopic]) === 'active'); + vote_cast($c2, $countTopic, 'against'); + $check('Thema schließt bei Erreichen der Zielzahl (2/2)', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$countTopic]) === 'closed'); + try { + vote_cast($au, $countTopic, 'for'); + $check('Keine Stimme nach Schließung', false); + } catch (DomainException $e) { + $check('Keine Stimme nach Schließung', $e->getMessage() === 'flash.topic_not_votable'); + } + $warp('+1 day'); + $dateTopic = topic_create($au, 'Thema endet gestern', 'Ziel für den Datum-Test hier.', 'Begründung für den Datum-Test hier.', $catId, 'bund', null, 'date', substr(Clock::nowStr(), 0, 10), null); + $warp('+2 days'); + maintenance_tick(); + $check('Datumsende schließt Thema', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$dateTopic]) === 'closed'); + $warp('-3 days'); + $au2 = cli_add_users(1, 'au2')[0]; + $ownTopic = cli_make_topic($au2, 'Thema zum Bearbeiten und Löschen'); + topic_update($ownTopic, $au2, 'Neuer Titel nach Bearbeitung', 'Neues Ziel nach Bearbeitung hier.', 'Neue Begründung nach Bearbeitung hier.', $catId, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 10), 0, 10), null); + $check('Autor kann bearbeiten', SW::$db->val('SELECT title FROM topics WHERE id = ?', [$ownTopic]) === 'Neuer Titel nach Bearbeitung'); + try { + topic_update($ownTopic, $c1, 'Fremd', 'Fremdziel hier bitte.', 'Fremdbegründung hier bitte.', $catId, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 10), 0, 10), null); + $check('Nicht-Autor kann nicht bearbeiten', false); + } catch (DomainException $e) { + $check('Nicht-Autor kann nicht bearbeiten', $e->getMessage() === 'flash.not_author'); + } + vote_cast($c1, $ownTopic, 'for'); + try { + topic_archive($ownTopic, $au2); + $check('Abgestimmtes Thema bleibt dauerhaft bestehen', false); + } catch (DomainException $e) { + $check('Abgestimmtes Thema bleibt dauerhaft bestehen', $e->getMessage() === 'flash.topic_locked'); + } + $check('Abgestimmtes Thema weiterhin aktiv', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$ownTopic]) === 'active'); + $warp('+1 day'); + $freshTopic = cli_make_topic($au2, 'Thema ohne Stimmen zum Archivieren'); + topic_archive($freshTopic, $au2); + $check('Thema ohne Stimmen wird archiviert, nicht gelöscht', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$freshTopic]) === 'archived' + && SW::$db->val('SELECT COUNT(*) FROM topics WHERE id = ?', [$freshTopic]) == 1); + $check('Archiviertes Thema erscheint nicht in der Liste', + !in_array($freshTopic, array_map(static function (array $r): int { + return (int) $r['id']; + }, topics_list([], 1, 50, null)['rows']), true)); + try { + vote_cast($c1, $freshTopic, 'for'); + $check('Archiviertes Thema ist nicht wählbar', false); + } catch (DomainException $e) { + $check('Archiviertes Thema ist nicht wählbar', $e->getMessage() === 'flash.topic_not_votable'); + } + try { + topic_update($freshTopic, $au2, 'Neuer Titel im Archiv', 'Ziel im Archiv hier.', 'Begründung im Archiv hier.', $catId, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 10), 0, 10), null); + $check('Archiviertes Thema ist nicht mehr bearbeitbar', false); + } catch (DomainException $e) { + $check('Archiviertes Thema ist nicht mehr bearbeitbar', $e->getMessage() === 'flash.not_author'); + } + $warp('-1 day'); + $similar = topics_similar('Neuer Titel nach Bearbeitung', null, 5); + $check('Ähnliches Thema wird gefunden', $similar !== [] && (int) $similar[0]['id'] === $ownTopic); + $check('Unähnlicher Titel liefert nichts', + topics_similar('Vollkommen anderes Anliegen ohne Bezug', null, 5) === []); + $check('Eigenes Thema wird bei der Suche ausgeblendet', + topics_similar('Neuer Titel nach Bearbeitung', $ownTopic, 5) === []); + + echo "== Profil: verschlüsselt an Public Key + Server-Signatur ==\n"; + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $pkHex = bin2hex(sodium_crypto_sign_publickey($pair)); + SW::$db->run('INSERT INTO users (pseudonym_hash, lang, created_at) VALUES (?, ?, ?)', [$pkHex, 'de', Clock::nowStr()]); + $pu = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pkHex]); + $sealed = profile_sealed($pu); + $check('Ausgeliefertes Profil enthält keinen Klartext-Schlüssel im Inhalt', + strpos($sealed, 'buergerabstimmung_versiegeltes_profil') === 0); + + preg_match('/chiffre_b64: "([^"]+)"/', $sealed, $cm); + preg_match('/server_signatur_b64: "([^"]+)"/', $sealed, $sm); + $cipher = base64_decode($cm[1]); + $sig = base64_decode($sm[1]); + $serverPk = hex2bin(server_sign_pk_hex()); + $check('Server-Signatur bestätigt (keine Manipulation)', + sodium_crypto_sign_verify_detached($sig, $cipher, $serverPk) === true); + $check('Manipulierte Chiffre fällt bei Signaturprüfung durch', + sodium_crypto_sign_verify_detached($sig, $cipher . 'x', $serverPk) === false); + $curveSk = sodium_crypto_sign_ed25519_sk_to_curve25519(sodium_crypto_sign_secretkey($pair)); + $curvePk = sodium_crypto_sign_ed25519_pk_to_curve25519(sodium_crypto_sign_publickey($pair)); + $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($curveSk, $curvePk); + $plain = sodium_crypto_box_seal_open($cipher, $keypair); + $check('Nur mit dem Ausweis-Schlüssel entschlüsselbar', is_string($plain) && strpos($plain, 'buergerabstimmung_profil') === 0); + } else { + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + } + + echo "== Meldegrund: Gesetzesverstoß ==\n"; + $check('Suche nach Schlagwort findet Volksverhetzung', isset(law_search('hetze')['stgb-130-1'])); + $check('Suche nach Paragraph findet § 185', isset(law_search('185')['stgb-185'])); + $check('Leere Suche liefert nichts', law_search('') === []); + try { + report_create(999999, 1, 'kein-gesetz'); + $check('Unbekanntes Gesetz abgelehnt', false); + } catch (DomainException $e) { + $check('Unbekanntes Gesetz abgelehnt', $e->getMessage() === 'flash.report_no_law'); + } + + echo "== Geltungsbereich (amtliche Auswahl) ==\n"; + $check('Deutschland', scope_decode('de') === ['bund', null]); + $check('Bundesland', scope_decode('bl:Bayern') === ['bundesland', 'Bayern']); + $check('Landkreis', scope_decode('kr:Bayern:Landkreis München') === ['landkreis', 'Landkreis München']); + $check('Unbekanntes Gebiet abgelehnt', scope_decode('kr:Bayern:Atlantis') === null && scope_decode('bl:Atlantis') === null); + $check('Gebietsliste vollständig geladen', count(SW_REGIONS) === 16 && array_sum(array_map('count', SW_REGIONS)) > 350); + + echo "== Themen: 1 pro Tag ==\n"; + $alice = cli_add_users(1, 'alice')[0]; + $topicId = cli_make_topic($alice, 'Testthema Nummer eins'); + $check('Erstes Thema angelegt', $topicId > 0); + try { + cli_make_topic($alice, 'Zweites Thema am selben Tag'); + $check('Zweites Thema am selben Tag abgelehnt', false); + } catch (DomainException $e) { + $check('Zweites Thema am selben Tag abgelehnt', $e->getMessage() === 'flash.topic_daily_limit'); + } + $warp('+1 day'); + $check('Thema am Folgetag erlaubt', cli_make_topic($alice, 'Thema am nächsten Tag') > 0); + + echo "== Stimmen & Favoriten ==\n"; + $bob = cli_add_users(1, 'bob')[0]; + vote_cast($bob, $topicId, 'for'); + $check('Stimme dafür gespeichert', topic_user_vote($topicId, $bob) === 'for'); + vote_cast($bob, $topicId, 'against'); + $check('Stimme änderbar', topic_user_vote($topicId, $bob) === 'against'); + vote_cast($bob, $topicId, 'none'); + $check('Stimme zurückziehbar (neutral = keine Stimme)', topic_user_vote($topicId, $bob) === null); + $slug = (string) SW::$db->val('SELECT slug FROM categories ORDER BY id LIMIT 1'); + $check('Favorit angelegt', fav_toggle($bob, 'category', $slug) === true); + $check('Favorit entfernt', fav_toggle($bob, 'category', $slug) === false); + $check('Gebiets-Favorit (Landkreis) gegen Liste geprüft', fav_toggle($bob, 'scope', 'landkreis:Ostalbkreis') === true); + try { + fav_toggle($bob, 'scope', 'landkreis:Entenhausen'); + $check('Erfundenes Gebiet abgelehnt', false); + } catch (DomainException $e) { + $check('Erfundenes Gebiet abgelehnt', true); + } + + echo "== Jury-Größe (1 %-Regel) ==\n"; + cli_switch_db($tmpDir, 'big'); + $crowd = cli_add_users(600, 'crowd'); + $reporter600 = cli_add_users(1, 'rep')[0]; + $target = cli_make_topic($crowd[0], 'Zielthema für die große Jury'); + report_create($target, $reporter600, 'stgb-130-1'); + $bigReport = SW::$db->one('SELECT * FROM reports ORDER BY id DESC LIMIT 1'); + $check('Jury = 1 % bei 601 Nutzenden (aufgerundet)', (int) $bigReport['jury_size'] === (int) ceil(601 * 0.01)); + $check('Quorum = 0,5 % (mind. 3)', (int) $bigReport['quorum'] === max(3, (int) ceil(601 * 0.005))); + + echo "== Meldung & Jury: Ausschlüsse, Fristen, Karenz ==\n"; + cli_switch_db($tmpDir, 'jury'); + $users = cli_add_users(12, 'u'); + $tX = cli_make_topic($users[8], 'Gemeldetes Thema X'); + $tY = cli_make_topic($users[9], 'Gemeldetes Thema Y'); + $jurorsOf = static function (int $reportId): array { + return array_map(static function (array $r): int { + return (int) $r['user_id']; + }, SW::$db->all('SELECT user_id FROM report_jurors WHERE report_id = ?', [$reportId])); + }; + + $r1 = report_create($tX, $users[0], 'stgb-86a-1'); + $j1 = $jurorsOf($r1); + $check('Jury 1: 5 Sitze (Mindestgröße)', count($j1) === 5); + $check('Melder und Autor nicht in der Jury', !in_array($users[0], $j1, true) && !in_array($users[8], $j1, true)); + $r1Row = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$r1]); + $check('Meldung wartet bis Mitternacht', $r1Row['status'] === 'pending'); + $check('Start zur nächsten Mitternacht (00:00 lokal)', $r1Row['voting_starts_at'] === Clock::nextLocalMidnightUtcStr()); + try { + report_create($tX, $users[1], 'stgb-185'); + $check('Zweite Meldung zum selben Thema abgelehnt', false); + } catch (DomainException $e) { + $check('Zweite Meldung zum selben Thema abgelehnt', $e->getMessage() === 'flash.report_already_open'); + } + $r2 = report_create($tY, $users[1], 'stgb-241-1'); + $j2 = $jurorsOf($r2); + $check('Jury 2 disjunkt zu laufender Jury 1', array_intersect($j1, $j2) === []); + $check('Kein Jury-Gate vor Abstimmungsstart', jury_pending_for($j1[0]) === null); + + $warp('+1 day'); + maintenance_tick(); + $check('Abstimmung um 00:00 gestartet', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r1]) === 'voting'); + $check('Jury-Gate nach Start aktiv', jury_pending_for($j1[0]) !== null); + jury_cast($r1, $j1[0], 'confirm'); + jury_cast($r1, $j1[1], 'confirm'); + jury_cast($r1, $j1[2], 'neutral'); + maintenance_tick(); + $check('Keine Entscheidung vor Ablauf der 24 h', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r1]) === 'voting'); + try { + jury_cast($r1, $j1[0], 'reject'); + $check('Doppelte Jury-Stimme abgelehnt', false); + } catch (DomainException $e) { + $check('Doppelte Jury-Stimme abgelehnt', $e->getMessage() === 'flash.jury_already_voted'); + } + $warp('+25 hours'); + maintenance_tick(); + $r1Row = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$r1]); + $check('Entscheidung nach Frist + Quorum (2:0 bestätigt)', $r1Row['status'] === 'decided_removed'); + $check('Thema entfernt', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$tX]) === 'removed'); + $expectedCooldown = Clock::addDaysStr((string) $r1Row['decided_at'], 3); + $cooldowns = SW::$db->all( + 'SELECT jury_cooldown_until FROM users WHERE id IN (' . implode(',', array_fill(0, count($j1), '?')) . ')', + $j1 + ); + $check('Karenz (3 Tage) für alle Jury-Mitglieder gesetzt', array_unique(array_column($cooldowns, 'jury_cooldown_until')) === [$expectedCooldown]); + + $tZ = cli_make_topic($j1[0], 'Gemeldetes Thema Z'); + $r3 = report_create($tZ, $j2[0], 'stgb-126a-1'); + $j3 = $jurorsOf($r3); + $expectedJ3 = array_values(array_diff(array_map('intval', $users), $j1, $j2)); + sort($j3); + sort($expectedJ3); + $check('Karenz + laufende Jurys schließen korrekt aus (Restmenge = 2)', $j3 === $expectedJ3 && count($j3) === 2); + + $warp('+1 day'); + maintenance_tick(); + jury_cast($r3, $j3[0], 'reject'); + $warp('+25 hours'); + maintenance_tick(); + $check('Meldung läuft weiter, bis das Quorum erreicht ist', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r3]) === 'voting'); + jury_cast($r3, $j3[1], 'reject'); + $check('Entscheidung sofort bei Quorum nach Fristablauf (behalten)', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r3]) === 'decided_kept'); + $check('Thema bleibt bei Ablehnung bestehen', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$tZ]) === 'active'); + + $warp('+2 days'); + $tW = cli_make_topic($j2[2], 'Gemeldetes Thema W'); + $r4 = report_create($tW, $j2[1], 'stgb-240-1'); + $j4 = $jurorsOf($r4); + sort($j1); + sort($j4); + $check('Nach 3 Tagen Karenz wieder losbar (Jury 4 = frühere Jury 1)', $j4 === $j1); + + echo "== Strenge im Normalmodus / Skip im Testmodus ==\n"; + if (card_supports_sodium()) { + $pairA = sodium_crypto_sign_keypair(); + $cardA = ['secret' => sodium_crypto_sign_secretkey($pairA), 'pk' => sodium_crypto_sign_publickey($pairA)]; + $pairB = sodium_crypto_sign_keypair(); + $cardB = ['secret' => sodium_crypto_sign_secretkey($pairB), 'pk' => sodium_crypto_sign_publickey($pairB)]; + $userA = ['pseudonym_hash' => card_identity($cardA)]; + SW::$testMode = false; + $check('Normalmodus: ohne Ausweis abgelehnt', card_confirm_ok($userA, null, 'confirm:/vote') === false); + $check('Normalmodus: fremder Ausweis abgelehnt', card_confirm_ok($userA, $cardB, 'confirm:/vote') === false); + $check('Normalmodus: eigener Ausweis bestätigt', card_confirm_ok($userA, $cardA, 'confirm:/vote') === true); + SW::$testMode = true; + $check('Testmodus: Ausweis-Aufforderung entfällt', card_confirm_ok($userA, null, 'confirm:/vote') === true); + SW::$testMode = false; + } else { + for ($i = 0; $i < 5; $i++) { + $check('Strenge-Prüfung übersprungen (kein sodium)', true); + } + } + + echo "== Allowlist autorisierter Ausweis-Schlüssel ==\n"; + @unlink(authorized_file()); + $check('Leere Allowlist weist alles ab', authorized_contains(str_repeat('a', 64)) === false); + $goodPk = str_repeat('b', 64); + authorized_add([$goodPk], 'test'); + $check('Autorisierter Schlüssel erkannt', authorized_contains($goodPk) === true); + $check('Nicht autorisierter Schlüssel abgewiesen', authorized_contains(str_repeat('c', 64)) === false); + $check('Doppeltes Hinzufügen ohne Duplikat', authorized_add([$goodPk], 'test') === 0); + $check('Ungültiges Format wird ignoriert', authorized_add(['xyz'], 'test') === 0 && !authorized_contains('xyz')); + + echo "== Sortierung: größte Netto-Zustimmung oben ==\n"; + cli_switch_db($tmpDir, 'sortdb'); + $sa = cli_add_users(1, 'sa')[0]; + $catS = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $lowNet = topic_create($sa, 'Radweg-Vorschlag mit knapper Mehrheit', 'Ziel des ersten Sortier-Themas hier.', 'Begründung des ersten Sortier-Themas hier.', $catS, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10), null); + $sb = cli_add_users(1, 'sb')[0]; + $highNet = topic_create($sb, 'Radweg-Vorschlag mit klarer Mehrheit', 'Ziel des zweiten Sortier-Themas hier.', 'Begründung des zweiten Sortier-Themas hier.', $catS, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10), null); + $sv = cli_add_users(6, 'sv'); + + vote_cast($sv[0], $lowNet, 'for'); + vote_cast($sv[1], $lowNet, 'against'); + + vote_cast($sv[2], $highNet, 'for'); + vote_cast($sv[3], $highNet, 'for'); + vote_cast($sv[4], $highNet, 'for'); + $listed = topics_list(['q' => 'Radweg-Vorschlag'], 1, 10, null); + $check('Suchtreffer nach Netto-Zustimmung: höchste zuerst', + $listed['rows'] !== [] && (int) $listed['rows'][0]['id'] === $highNet); + + echo "== Kontolöschung (DSGVO) ==\n"; + cli_switch_db($tmpDir, 'gdpr'); + $pairIds = cli_add_users(2, 'cd'); + $carol = $pairIds[0]; + $dave = $pairIds[1]; + $carolTopic = cli_make_topic($carol, 'Thema von Carol zum Löschen'); + $daveTopic = cli_make_topic($dave, 'Thema von Dave bleibt bestehen'); + vote_cast($carol, $daveTopic, 'for'); + fav_toggle($carol, 'scope', 'bundesland:Bayern'); + $carolTag = vote_tag($daveTopic, user_pk($carol)); + account_delete($carol); + $check('Nutzer gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE id = ?', [$carol]) === 0); + $check('Stimme bleibt anonym erhalten (nicht mehr zuordenbar)', + (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ? AND voter_tag = ?', [$daveTopic, $carolTag]) === 1); + $check('Favoriten gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM favorites WHERE user_id = ?', [$carol]) === 0); + $systemId = (int) SW::$db->val('SELECT id FROM users WHERE is_system = 1'); + $check('Thema entkoppelt (System-Konto)', (int) SW::$db->val('SELECT author_id FROM topics WHERE id = ?', [$carolTopic]) === $systemId); + + echo "== Themen-Ende: Datum, Zielwert, Kombination ==\n"; + cli_switch_db($tmpDir, 'endform'); + $readEnd = static function (array $post): ?array { + $_POST = $post; + $result = parse_topic_end(); + $_POST = []; + return $result; + }; + $soon = substr(Clock::addDaysStr(Clock::nowStr(), 5), 0, 10); + $check('Nur Datum ergibt Modus "date"', $readEnd(['end_by_date' => '1', 'end_date' => $soon]) === ['date', $soon, null]); + $check('Nur Stimmenzahl ergibt Modus "count"', $readEnd(['end_by_target' => '1', 'end_value' => '250', 'end_unit' => 'count']) === ['count', null, 250]); + $check('Datum + Zielwert ergibt Modus "both"', + $readEnd(['end_by_date' => '1', 'end_date' => $soon, 'end_by_target' => '1', 'end_value' => '80', 'end_unit' => 'count']) === ['both', $soon, 80]); + $check('Ohne Auswahl kein gültiges Ende', $readEnd([]) === null); + $check('Datum in der Vergangenheit abgelehnt', $readEnd(['end_by_date' => '1', 'end_date' => '2000-01-01']) === null); + $check('Prozent über 100 abgelehnt', $readEnd(['end_by_target' => '1', 'end_value' => '120', 'end_unit' => 'percent']) === null); + cli_add_users(200, 'pc'); + $pct = $readEnd(['end_by_target' => '1', 'end_value' => '10', 'end_unit' => 'percent']); + $check('Prozent wird in Stimmenzahl umgerechnet', $pct !== null && $pct[2] === 20); + $bothUser = cli_add_users(1, 'both')[0]; + $bothTopic = topic_create($bothUser, 'Thema mit Datum und Zielzahl', 'Ziel des Kombi-Tests hier.', 'Begründung des Kombi-Tests hier.', $catId, 'bund', null, 'both', $soon, 2); + $bv = cli_add_users(2, 'bv'); + vote_cast($bv[0], $bothTopic, 'for'); + vote_cast($bv[1], $bothTopic, 'for'); + $check('Kombination: Zielzahl schließt vor dem Datum', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$bothTopic]) === 'closed'); + $dateFirst = topic_create($bv[0], 'Thema schließt am Datum', 'Ziel des Datum-Kombi-Tests.', 'Begründung des Datum-Kombi-Tests.', $catId, 'bund', null, 'both', substr(Clock::nowStr(), 0, 10), 1000000); + $warp('+2 days'); + maintenance_tick(); + $check('Kombination: Datum schließt vor der Zielzahl', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$dateFirst]) === 'closed'); + $warp('-2 days'); + + echo "== Testbetrieb: Zustand in der Datenbank, Ende löscht alles ==\n"; + cli_switch_db($tmpDir, 'testmode'); + SW::$testMode = null; + $check('Testbetrieb ist im Auslieferungszustand aktiv', test_mode() === true); + $tmUser = cli_add_users(1, 'tm')[0]; + $tmTopic = cli_make_topic($tmUser, 'Thema aus dem Testbetrieb'); + vote_cast(cli_add_users(1, 'tv')[0], $tmTopic, 'for'); + fav_toggle($tmUser, 'scope', 'bundesland:Bayern'); + test_mode_end(); + $check('Testbetrieb beendet', test_mode() === false); + $check('Themen gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM topics') === 0); + $check('Stimmen gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM votes') === 0); + $check('Favoriten gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM favorites') === 0); + $check('Konten gelöscht (System-Konto bleibt)', + (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0') === 0 + && (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 1') === 1); + $check('Kategorien bleiben erhalten', count(categories()) >= 20); + SW::$testMode = true; + + echo "== Einrichtung des Echtbetriebs ==\n"; + cli_switch_db($tmpDir, 'setup'); + SW::$testMode = null; + @unlink(authorized_file()); + @unlink(test_keys_file()); + $base = ['eid_mode' => 'demo', 'eid_server_url' => '', 'eid_server_cert' => '', 'eid_server_key' => '', + 'eid_client_url' => 'http://127.0.0.1:24727/eID-Client', 'authorized_keys_url' => '', 'nect_start' => '']; + $validate = static function (array $over) use ($base): array { + [$errors] = setup_validate(array_merge($base, $over)); + return $errors; + }; + $check('Leere Freigabeliste ohne Abgleich-Adresse abgelehnt', + in_array('setup.err_list_empty', $validate([]), true)); + authorized_add([str_repeat('ab', 32)], 'selftest'); + $check('Freigabeliste mit Eintrag genügt', $validate([]) === []); + $check('eID-Modus ohne Serveradresse abgelehnt', + in_array('setup.err_server_missing', $validate(['eid_mode' => 'eid']), true)); + $check('Serveradresse ohne https abgelehnt', + in_array('setup.err_https', $validate(['eid_server_url' => 'http://eid.example']), true)); + $check('Nicht lesbares Zertifikat abgelehnt', + in_array('setup.err_file', $validate(['eid_server_cert' => '/kein/pfad/cert.pem']), true)); + $check('Gültige eID-Angaben angenommen', + $validate(['eid_mode' => 'eid', 'eid_server_url' => 'https://eid.example/soap']) === []); + $saved = array_merge($base, ['eid_mode' => 'eid', 'eid_server_url' => 'https://eid.example/soap', + 'nect_start' => 'https://nect.example/start']); + $check('Einstellungen gespeichert', setup_save($saved)); + $loaded = setup_load(); + $check('Einstellungen unverändert gelesen', + $loaded['eid_server_url'] === 'https://eid.example/soap' && $loaded['eid_mode'] === 'eid'); + setup_apply($loaded); + $check('Einstellungen überschreiben die Vorgabe', + (string) SW::$cfg['eid_server_url'] === 'https://eid.example/soap' + && (string) SW::$cfg['eid_providers']['nect']['start'] === 'https://nect.example/start'); + $check('Nicht vorgesehene Schlüssel werden nicht übernommen', + !array_key_exists('session_idle_minutes', setup_load())); + $check('Unerreichbarer eID-Server meldet Fehlschlag', + setup_probe(array_merge($base, ['eid_server_url' => 'https://127.0.0.1:1/soap'])) === false); + $token = setup_token(); + $check('Einrichtungsschlüssel erzeugt und stabil', strlen($token) === 32 && $token === setup_token()); + @unlink(authorized_file()); + @unlink(test_keys_file()); + $realKey = str_repeat('cd', 32); + $testKey = str_repeat('ef', 32); + authorized_add([$realKey], 'issue-card'); + authorized_add([$testKey], 'test-login'); + test_key_add($testKey); + $check('Testschlüssel zählen nicht als freigegebene Ausweise', authorized_count_stable() === 1); + test_mode_end(); + $check('Testschlüssel beim Umschalten entfernt', !authorized_contains($testKey)); + $check('Echter Ausweis-Schlüssel bleibt erhalten', authorized_contains($realKey)); + SW::$cfg = SW_CONFIG; + @unlink(setup_file()); + @unlink(setup_token_file()); + SW::$testMode = true; + + echo "== Sprachtabellen ==\n"; + $dupes = static function (string $const): array { + $src = file_get_contents(__FILE__); + $start = strpos($src, 'const ' . $const . ' = ['); + $end = strpos($src, "\n];", $start); + $body = substr($src, $start, $end - $start); + preg_match_all("/^ {4}'([a-z0-9_.]+)' =>/m", $body, $m); + $seen = []; + $twice = []; + foreach ($m[1] as $key) { + if (isset($seen[$key])) { + $twice[] = $key; + } + $seen[$key] = true; + } + return $twice; + }; + $check('Keine doppelten Schlüssel in SW_DE', $dupes('SW_DE') === []); + $check('Keine doppelten Schlüssel in SW_EN', $dupes('SW_EN') === []); + $outside = static function (): string { + $src = file_get_contents(__FILE__); + $out = ''; + $cursor = 0; + foreach (['SW_DE', 'SW_EN'] as $const) { + $start = strpos($src, 'const ' . $const . ' = ['); + $end = strpos($src, "\n];", $start) + 3; + $out .= substr($src, $cursor, $start - $cursor); + $cursor = $end; + } + return $out . substr($src, $cursor); + }; + $body = $outside(); + $orphans = array_values(array_filter(array_keys(SW_DE), static function (string $key) use ($body): bool { + return strpos($body, "'" . $key . "'") === false; + })); + $check('Keine verwaisten Textschlüssel', $orphans === [], implode(', ', $orphans)); + $missingEn = array_diff(array_keys(SW_DE), array_keys(SW_EN)); + $missingDe = array_diff(array_keys(SW_EN), array_keys(SW_DE)); + $check('Deutsch und Englisch decken dieselben Schlüssel ab', + $missingEn === [] && $missingDe === []); + $emptyVals = array_filter(SW_EN, static function ($v): bool { + return trim((string) $v) === ''; + }); + $check('Keine leeren englischen Texte', $emptyVals === []); + + Clock::setTestNow(null); + putenv('BUERGERABSTIMMUNG_DB'); + array_map('unlink', glob($tmpDir . '/*') ?: []); + rmdir($tmpDir); + printf("\nErgebnis: %d bestanden, %d fehlgeschlagen.\n", $pass, $fail); + return $fail === 0 ? 0 : 1; +} + +function cli_seed(int $count): void +{ + $created = 0; + $votes = 0; + SW::$db->tx(function () use ($count, &$created, &$votes): void { + $now = Clock::nowStr(); + for ($i = 0; $i < $count; $i++) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, is_seed, created_at) VALUES (?, ?, 1, ?)', + ['seed-' . bin2hex(random_bytes(28)), 'de', $now] + ); + $created++; + } + $seedIds = array_map(static function (array $r): int { + return (int) $r['id']; + }, SW::$db->all('SELECT id FROM users WHERE is_seed = 1')); + foreach (SW::$db->all("SELECT id FROM topics WHERE status = 'active'") as $topic) { + $turnout = random_int(15, 60); + $forShare = random_int(25, 75); + foreach ($seedIds as $userId) { + if (random_int(1, 100) > $turnout) { + continue; + } + $choice = random_int(1, 100) <= $forShare ? 'for' : 'against'; + + SW::$db->run( + 'INSERT OR IGNORE INTO votes (topic_id, voter_tag, choice, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + [(int) $topic['id'], vote_tag((int) $topic['id'], user_pk($userId)), $choice, $now, $now] + ); + $votes++; + } + } + }); + printf("Demo-Nutzer angelegt: %d, Stimmen erzeugt: %d\n", $created, $votes); +} + +function cli_jurysim(): void +{ + maintenance_tick(); + $seats = SW::$db->all( + "SELECT rj.report_id, rj.user_id + FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + JOIN users u ON u.id = rj.user_id + WHERE r.status = 'voting' AND rj.vote IS NULL AND u.is_seed = 1" + ); + $cast = 0; + foreach ($seats as $seat) { + if (random_int(1, 100) > 80) { + continue; + } + $roll = random_int(1, 100); + $vote = $roll <= 55 ? 'confirm' : ($roll <= 85 ? 'reject' : 'neutral'); + try { + jury_cast((int) $seat['report_id'], (int) $seat['user_id'], $vote); + $cast++; + } catch (DomainException $e) { + + } + } + printf("Simulierte Jury-Stimmen: %d\n", $cast); +} + +function cli_main(array $argv): int +{ + $cmd = $argv[1] ?? 'help'; + if ($cmd === 'selftest') { + Clock::setTimezone((string) SW::$cfg['timezone']); + date_default_timezone_set('UTC'); + return cli_selftest(); + } + sw_setup(); + if ($cmd === 'cron') { + maintenance_tick(); + echo "ok\n"; + return 0; + } + if ($cmd === 'seed') { + $n = isset($argv[2]) && preg_match('/^\d{1,5}$/', $argv[2]) === 1 ? (int) $argv[2] : 400; + cli_seed($n); + return 0; + } + if ($cmd === 'jurysim') { + cli_jurysim(); + return 0; + } + if ($cmd === 'issue-card') { + $n = isset($argv[2]) && preg_match('/^\d{1,4}$/', $argv[2]) === 1 ? (int) $argv[2] : 1; + cli_issue_card($n); + return 0; + } + if ($cmd === 'sync-keys') { + return cli_sync_keys($argv[2] ?? ''); + } + if ($cmd === 'setup-token') { + if (!test_mode()) { + echo "Testbetrieb ist bereits beendet; ein Einrichtungsschluessel wird nicht mehr gebraucht.\n"; + return 0; + } + echo setup_token() . "\n"; + return 0; + } + if ($cmd === 'config') { + $stored = setup_load(); + echo $stored === [] ? "Keine Einstellungen in data/config.yaml.\n" : ''; + foreach (SW_SETUP_KEYS as $key) { + printf("%-20s %s\n", $key, (string) ($stored[$key] ?? '-')); + } + return 0; + } + echo "Aufrufe: php index.php selftest | cron | seed [n] | jurysim | issue-card [n] | sync-keys [url] | setup-token | config\n"; + return $cmd === 'help' ? 0 : 1; +} + +function cli_issue_card(int $count): void +{ + if (!card_supports_sodium()) { + fwrite(STDERR, "Abbruch: PHP-sodium erforderlich.\n"); + return; + } + $dir = SW::$dataDir . '/issued'; + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + fwrite(STDERR, "Abbruch: issued/ nicht anlegbar.\n"); + return; + } + for ($i = 0; $i < $count; $i++) { + $pair = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($pair); + $pkHex = bin2hex(sodium_crypto_sign_publickey($pair)); + $handle = bin2hex(random_bytes(16)); + authorized_add([$pkHex], 'issue-card'); + $file = $dir . '/' . $handle . '.key'; + file_put_contents($file, base64_encode($secret), LOCK_EX); + @chmod($file, 0600); + echo "Autorisierter Ausweis ausgegeben.\n"; + echo " Schluessel: " . substr($pkHex, 0, 16) . "…\n"; + echo " Ausgabe-Link (im Browser oeffnen): /claim/" . $handle . "\n"; + } + echo "Hinweis: Nur diese Schluessel koennen sich anmelden. Link der URL der Seite voranstellen.\n"; +} + +function cli_sync_keys(string $urlArg): int +{ + $url = $urlArg !== '' ? $urlArg : (string) SW::$cfg['authorized_keys_url']; + if ($url === '') { + echo "Keine Quelle konfiguriert (authorized_keys_url leer).\n"; + echo "In Deutschland gibt es keine staatliche Liste aller Ausweis-Schluessel;\n"; + echo "echte Pruefung laeuft ueber die BSI-Zertifikatskette im eID-Server.\n"; + echo "Diese Funktion ist der Anschlusspunkt fuer eine eigene Trust-Liste.\n"; + return 0; + } + if (preg_match('#^https://#', $url) !== 1) { + fwrite(STDERR, "Abbruch: nur https-Quellen erlaubt.\n"); + return 1; + } + $ctx = stream_context_create(['http' => ['timeout' => 15], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + fwrite(STDERR, "Abbruch: Quelle nicht erreichbar.\n"); + return 1; + } + preg_match_all('/[0-9a-fA-F]{64,128}/', $body, $mm); + $added = authorized_add($mm[0], $url); + printf("Allowlist aktualisiert: %d neue Schluessel aus %s\n", $added, $url); + return 0; +} + +if (PHP_SAPI === 'cli') { + exit(cli_main($argv)); +} +web_main(); diff --git a/v2indexsplit.zip b/v2indexsplit.zip new file mode 100644 index 0000000000000000000000000000000000000000..59d7869fd1ec34883cdc6b6f887476aa17d41bff GIT binary patch literal 86464 zcmb5VQ>-vRlr(s4+qP}n_WiDH+qP}nwr$(C?VXw3WHZS>JK0XU(+{1>c{yFxm1+fP zU=S#P{~S6pAUglG`F|E902}~A7ZXP_6GsC>Cua*A8y8zMdQ}xj0ARsJ2h0DQF7D6( zfFS>Q{=XK5{}BuA|A^(}X!O70q5KCp#DCTHkFrIDvp2W@ z|EhCFW5E`i9nEJ=-5wCQ5l~dO!v>4wh0f6Us9C2>G-tIRE0l5Z-wZTu8%8jZPxgt0$vuqr_5egO=QNi4Qxu6UV04>Xj^%Ni_#=*Z0>`ld=QF ztQXMV=P22cc#-_?xA`)9m>;D44z}v~Ht*HnryU^lgSU04A&K10gQ6e7r<&Ge_W-;o z8?$<`a-($nGCoUFx+K&YdPwWBP2H-EC>AF1JGg`Us8~`m6RD#ThLok^&Sg4?Li(I! z^hIUoglRFq#>k@fRT)1qZ7~uFYHxM|3yXa`sR)yzaR>{VyuX$Ub53Go#<9t;G;Ks4 zopk7v81r@!VJKriPnJSLN=PJ=O!+~R@!Ne0Q^xq^XuLpXOSp8*_P9zCK^B!6scu4U zilya2Ujx$|ZG;i}JORypsIge!7=8=UGcy|<#RGdqssQ*N+O37m=>8GDi4bLW>O^$$ z35NH#dI;W9A_5^%SdQMZJa)8W+v`qAnhBk*gJoth06Ppv{ z1%>G1x~~?22|)uhCkEHI{OS`XWw%i~5xhn*lC0xo#ewLgRUDu^rP7^%jV0Yz|>U{zrrPi(6S3r#0g=5rtlI>^@}E9J@j!CrBh z1fr05Ib*ztf&ztsq}4d)`;5oxA$E>PejhcKQcfa+m8{+F$q~>*PZLZqkSnw7iPALj z44bG`#EhkVBjC1w$q>811=COjM`$S+b6Pf*(J>U-Ja9%7y(-vit-+xrir0n1`s#e| z>N__AI9#FpkB~d+25D}M;_ViM`}ij2ew?JqAt;`H^uv}CfueLLSj?tDbi^Z%c8ZXD zQWwr7i``6+WSLG zcTH_iI!9iPICUQI!@FBhOIQudZJ1sJziyhxtc51{BWH5hg6m;i4rw*tK9RVrR%xOe zfSmk~>lIqK)RCWUb@_sMvrXJ{{kF2=3V+rOSOZAHvl6tIDQ23dS%Jun0a>)c$ci}( z68W@(Qu6+?t8F~|aa`IdmuSEl8N$G}$*h!I%hkU?9(}hbxlkLz^If1Upr+G~H}oR+ z-k>qBok2FwM@=`RT<=!MAL2=g(b`-tL_BuaKzHOYQC*#~x4{n}gJ!Zmy``Y*_dw&F zjdCsmI;$8Z+a+uHZdS9Py_~vDz?+=wH}N(kYqt1h07B|W?aC8Vao zmt$Q5A9>`?5%w1>BdH?C-o#t6K+ZW#ZT&RXR4@;ff*NIKV=frD}UgSvyu5*IKL9UzP%xNd3#Gj z9ddS80p$(Aj?sQm1)45d@d9he(q8<81UlnUMSeGd zCm%?eLY^+&33K>Kw}1SBwJxuHSq0#Ew%mO~g+=ThIy>t(UPYBP{`7@H;5rUk*Ix`L!^V@R_`tSq;sXsMd zZ5->K&!$OmXgGDo@R93iSslRHD6!n2hapDvt$3J+hd0_0-M$=XV@$bBKzjw9a+7Pt zovXXEV&}8Pvw8gmo_UZgnh}?qAJ#`F*;aCc^=yC7V!sW51V_d(p@Q+e_|aLN250b9 zOpI1$+O#lwv7w_DOXG67e-FbW6V94gy~L_PuWF#RWGgLFnbM_kd)VypUx%7??6fPv zcbXz-v~;kd^A>IsFqSvp?Qbd~TH<$}{2hec{E@;5ar6(nXqpWKJZ@#@KiuWll?vX(xgc=Rnd#2)I*W>9H9l&p!PdCL2 z+`YZoI}hQsb3Qt{;RWYMe+utmy=eU2n{+kY#O*Y6LiR5HzC9iWuIjol7&H&$!Mrg{ z@!w~8qDCQc(y~u9ne(@t&VeR^&-C}`duqzpup(`Q0=8=AMh$_FIQlvO2(LX3A3o3z z@nB{@)o;CT*#D_A{$EufQXub14F&*E1rGo~_&=%wWfLbS3p?BYRT!x2C?Bz*_{`O{ z*JnuGVkOomUj)NqLhb65(iljj`ba3pHKd3ji@VxjN}&IG)sb+o!xi{che5WI=6KyM z{KWIW9(in=H*4*S16~dOo8-7@)ncYQ+t}&Fr`29!kZLDC&K{9m?&McUveSIr4@s27 zaQtv1GiCEOG4N-XS>LyBcpmIgY}N;2LhO$r&uBMl3Wj9$=v#nD$M=LRs*q`?*XfAV z+`Q5liy37>?mbQr5F#=W>SXqv18M2BM;A3X5(mDQN}omo^z2C26v(>W(7*o*VJ5o> z%BmwlK7R{9fcEUci-+U1<>?E|W2%_HBgQ*Ee;EZ9`%gugz$>?7*qn9 z=LVcei`Kfw<8$`?uwS-qyGe^^AU_k?I}}+YDk(_*&>RfM6td!RdBIbtVv+2L5NRjc zR7h-K@^3SyOp}RAkiKIS&(o;uvMN@_!+vng%VG+|`%Y;vJ~;#+WfC&u2Db^Oq{Jh) zKBeQmn4|P~GmDy}IQ6}X+ZP|ZZO+SkXA+b72=05!?Q8s;aSIPzH-+cAUy|QkjNL>4 z^7)xpO;ni(%}wuEBGKw}lYEYEG)}TR5Ud!yS>=RaN>2Om#^dV zHX3kW%6=qCX~#>OqKL+u7s}kB97D8|bTAX12A8rH#h2AQdL3>ouo((aRSkw- zlSZ-=g)N0u;n!mL-o0V_xv-W=W~ag{f6YUKpt{8i;dw#~_aup0mo&(Yj4hXW`d_ z^X2ZriIoJ5+I-dL;q(P=)+b}oa8Wv;kNnYKHd|z^I2+YHq*d0EYKxc8m}J7e_zQck zWJuEu^hyj%3sSLI-z!tNlu&SsA~C*3!a4DC1`ra+wn*y4UM2m z8oSCb5-Th#V`Y}k*xpYy;1sBvSKy>!Zm`Mhi8+(*M|-YsXBv=8*$yMl;HAqk_ps5)ht2^G0R=isX3Og8~7J9rljh~5;kb~tLxEZ@NZ?Skiyzp zj;~Bja><31wY^m3l&ur1W^rNB_)?BDms|JB^zwQkh?@@X^g4ko7ea+y(*SLLR#9;% zi*U_GpLm?80!TAVPq312J#4co`w+{`T0_P%MPQ^8-vsEULX%1dZYu>UD{0Ynhvk@$ zS=nZtdl-(Skzv|vBGr7!+7qmti1A}wG+R<3V16s-i9p{FT}Acwrc-K8r~PdZCJ*8o zICBM0!(HB0{_>2R)UUZbXF9Xe?%cl}4@P{QWVyNUs~4g^s529{$TOtm(bsH>Exo|z ztKQ{6gIIk^q;_Z-T3HRvj#y|402MOjR+Z4X=U#|MbY7~{5H^@ht^4~nzzBj52J>~5 zpeDlfb3_y4Bgz&Xzr4kb$;dI-ASlKJbBPLEMd|BH}z-Fuj;LLOsWf!8iKZ7+`3T^QxzYMm@S( z%dQwuo|y7=Rc%+El$_u+Ej_oFft{Kr3uv74vPvN)JzRfem$MnZP)nI-LHv0W>yD} zy7w~bVzAGy^pD%i&d%69GNy)sU?G(xH@>fc;_{zL&W;#~e3eMq$V2;d-D7JNbgW(w zuqOLB;8>gd0RB4*k%u<*w*vtHTz~=qkp7P>Bx_({>uh3cU~6RZzcEpW%9ib>0D{k( zdNcykRb+6f4>5XhzhVtidEgR{NJ7ho&OO>u%c>Q*;_%)x_9yU1Lu`zZwZx`R{rqf3 z2D90$BU^aTdZQ<=eqIPi`IMAA({;#A3v-`xp5^AmBaKVD+)o5m4v~G!jNFrlYJg4U#kR z*9sHb#c<3DA`Rt?AdBH(I}&ZgF8GYZZO;aPx$o9JyQi=s=p3=)_6hl`(=qpRPJI;` zO(3VSpkak1GJ7~&v`UeCIndB8yV(|HV1?`K2soHBqkaQ#xflR9xTOs6rL2i!OpYMI zDs9v*?2yQsPoM8a`)CKlJ#;~w%Rldy@D#SMU}h8J=4+h$=mcTJ@egZ3KCuRWdD!fn zK4}`IIH#gF4ccV1WKaPrPZk(St6Q1u1n|85M^9JPgsn2gMXL<~WOF-3wr-W2s?5^} z6)RG<;s}7p&^@}A)Ra8E3g6cS;zVdYI`7;_C_gPg_LDo=hZTyBK4TDi_CNasJD{3q z!Mod!ikQ!R1@4A$ALj;?(Il$?;yNRxR;w@;#&8mi^;*cZg&g}0HICT6l2E=FyQxpYN^l!#rqD;H-<%Hy}q^5l_8QYDtTYPdL2t0PB#JEFQ%|q#w#Jy zp?O0SG0fmfM~fi=J=%lTP(>Thj)$>4jg1C8L5md~!pzx1JPiydf%s-Fb*S)MTALx+ zuw}S>XQzb}MpQ8{w!A3s?g%h8ERn7s;CH_|%B)P5oh?ac!{IGcRU@pgtCVk{oFj*i zOm@C$5q+|w%gE{N>b2nHIhpX-*ylLEfD6kYQeRAWycAJvw+f$Kk@{~o*I)43(FruI z9LAkht27Oi&A-t9y)8hNy$#EN0RS|h0RV{q$F>kNaJ6%^a5nj0eVZ0FtGFUIl%LtU zc4|>*iDc_j;-zbIt|7T4Ib_OWTvcXD$9@p2aKY9LarKY8mKoxB6Fi+3HI$Ll&s-;; zxq**|Y6<8ej5xwcqiaDbk%68EcWDtV+ii-ym?RoA5XFSUz>}jeWKT;7ekTf)!m8D@ddHp|%NP-nRYgI;C{$1p_{A#ijA zaKxyvOLGj1+V*bJNqO@KjG?(TsR2vD+yojCH%B@;uW!|cbpihv!YnODchCPSd%eJW z_>m@oCndHck8|hO1A2|wJaS!L3JoihkURtn2}e+1?1pfW6fHB3`fd;Fkj)+~__5ZQ z8eU!{<2tXah{{cnw%vCrcE9gA%ar~F_}%DL>jUYfM(qh0Iktan=zR*1AaW#W+MN(W zYm@73?LEKsb5vTt@Q8fs+$$bUg#yh@+6W)Yy5pUI-Wv2B^uzIzCNCBJRzBddvq{)- zGzO*SN5c>H*v_Fh_MEP}1;TVG#iV6(T91?cyk$I&_%tHpR;B8EnFXSFIgz)oK??X#PT=@DG~w3I~76BhsPtnvh{0R!Eo2As}fb z4_;4r2j@Yo5qZyWCk8pkn%5LvLQq2weS9NP8Xd<%&Oz2{26QUcF~}%CC%F5`T9kzuDUg#7WPC;wwe@DQY!&=_QKgD~JU`)zuy=u` zn+-}Sd7^k#s`^doYli2nN+q=|vj_`|Iw|Q3&ilQ_-}Bef=Dm&izp3&6Y$;>^k9UA1 zBZuw(4=pE}xCV3>z$N;F8=cV4n ztS^WfyAc5%-=$^j_x;vVW^udS?wMuGjzCh^*lZ?!y|FK*>SP*s_Ufr@O4M3YVin!# z`2`N6-(UUOm}t_0PKlarRii}qAna);YvTK)*wtN25@Fjp_YG*?`s?by6J5MMAmjKw3Q1hvv9Yq1PZ{Q^M36WATeP zzDp&4xsjnuu7}~{?~&c8vDv1n>&)bqCl0M_jY_m2QYldR=4kAUSHFN$iSquw(&u!z z?ET>RL2)~`%24$rO!~NOwy+DeM!SiU68#3qx&4loSR$a2UEX}L}V+J@od#f>qHTN4As)Gw~8hJ)eyU;$wO&s8mwYo*Do9lrrokr z@|=Z$c2|D)2xuPw0ZKL_Rvd9Zs)nB06a+;4M1;(iBqQ#x}Dg%`Sz33&La6K7dE9SRU z8Vl`WIP6#%{qb$dZ3Iwv-su%4PCIk%g}2ak!;CZQvfHhsE>$(pI1?wL$nE1U zb$E^(1g#{CG(U49=EC;Ho!=+b019$8(tqiRizQpL)UBe*hpW}R`!iF1!Jp*fs7Q#c^7NDAheTzM3GvqdVM%bK!90tR2w`W^e#h z3{-j~xMrkPs9oA(Ev0S?q;A~60x|0HOu%4TA}bD5ksyPSzzC+437_HCReLH~>%JWB ze6Tou22d?4KJy_|g{;&j(=_XZCVR(^;xr(z37!xk_&o<`U<_KHEe5p`h}yW`Q&$U2 z#*9cTI`PlgBfapilx(l-v?+tSvWE**>fY!vcFlCH>6!7C9Ed5|r>s+awL>#6g6^gJ zXu01QlKh()pl-WT*?R0}yA9_L%NY+Ud`PQcdd#Ob`=rEhh@7w;EDQO;+4;5W2VDSw zXt0^LF3sQ)Bq6F3=ey$r!eBJHwh&aSj4y=~q-#HHVTf0=CCSApVlbC(phpy?0E0~a z)+x9UDg?6Y+vuaEhERg^qN)7pLhEZ!AT!JNC6Y#S125bE7{z~Q!CAq}SM|}4Lr#__ zTF)#v&h>x4krWAeu=LV_h7Zw0Dh>k&hx!BkxH3a_EM&Z!D(xXTd zY7H&5Z$a8GUi}Q&OU`GWIYL1cR9SWL7ETLS5NWt`6p1g0G7<-{of|D=2!NC_;w(gn zzki)oQ!CgWkxs} zSQlZ}mBLCu&b=7MzffktBP~_wXyB+2ojzBOvA5;XGoo>&KE)&BH!UyWxoCHyCX{7hVZ@E>%OfX>9~=<#((z~WoD z(nLBAP@<90GO)7i+hx_#nRr=8Kvk62l4u45=+hYhLJF@ZDE_#RE$8sV8R(oDwE&;( zfD>^YXF#1U33#gUu;(%;CzlNt$W=V1mAwJ58gJKJ%_NZFo{G8M0UC&}1h)ne-GorY zbSMyi(=}-wm4W;}ryy=J^Xs%OEc*N=K$j zuCn70kTELo&pNAW#i|bEu>nH}Cc0sn2*hMHuB}jeRtA$w=3YylD+iPaj2s;~AlQ%x zjl2XY!yTgG0BkOdu72W3=-iON%Cnv?d?9CZ4I%%4PWc%iyhjS-aF2u)q{V0%$9}C8 zb425r4~{v_wMLt-?^lR;*n!sZ6o{mg+v*1O0nC+O-0dYg2Jx?dQJoEKyq{QGbUF1% zgW2^N1>oEAX`3zp)Hd;r^mj|56W`^W%yBED_e@HR88qqY;rw)%AH@1@?zSV+i8p}n zplrY*s+mLXb^$GH?Tk{>)sf*1&137FjL3vqE_4uVUEaJyFn5 zaHdK4y2tS1MBu{o{mtG>A5u!O|Bfr4DFC{3_xgQ{7aCR#>5OR(#1yOHTj(Sr7v+ri z!#;-5V>~*(U~$57IfzOiBneb5v8j>kkIg-vkUh7;ZC4eEafDYEC?ABcEEp-|Q<@391lVc2D+(!wIKw7 zK^NI9a@ght(75K@xeF|SDh3NKPfn1(-6;og1n^#>^wZQpI0a^9lqVDCF4eN^-kdj6 z$M2;-{?W*XiqCM8Ob~({ZD4M@bcb9`hhiIQqtU=7gj~hOnpe#;M33V^U#hiexN7(O z88q*6Ti;9b9_#wgbBGdzyuVVjl27!yB9(#VvyMHv5vvsie|gC$JY-{h9imH(ywWqwX!h z+lhTU4m=r9yoommb}{#^IUlNiARG(km`r*W`D)*69GwuBNFz3(?Q%IL(2Il}%mgB_lz@fZmW6OaCao;~oI4GDhZ8S1^ zQXtc5`9TAmB7E_$v-f#T^LcVuF4wM;_Zgm0jUNqMu3Z;-E&Uu4rg3vqldL$oF-AYZ zp5mt2eM)a<d9T4nk11Ffa4;SuI3&qy1`FZG zsfRaiLSW4u9Ok*$1PLEW1-b%VspEE~76O4)GB6I=n7LkuIzBx6*H8vdo5OBdmUxmh zT(Joe>o&n8YVp}Wi)UrU7*VgV)*1JvRzeot)d`OfV2A`MGCk%XS%cR&39z-@YVms< zKgJ(=@jFKJxI4s63%K7LD4So2>2zo!how@=!YWzY%ZzAf%g0GW55maN!XB_0mYt!g-dn{mn9e-)zP*h5j1sIBG3v*ecIxy^ z!?TV-n;}cu_1GFuQO}F|V#nC(xS+i?Q1d%t$Ot?$FPPMNtoh@7fOj26@=~G>u#UMN z3625ADmQrp_AV~o!JHLMTOmEA&y`F?SFc_PZC7(sP;Q=&@~S+=$9>wL`|rC$<(!7Z zry!Zc045#?V|Z6Eul4MKS*GI2!On~lmVAB#UD$7?xLD6?FxG}r-ZNX12;^drL2B17 zYsl9w@t+(=^N)6@bDgN-#i2@&Q-j$ODoc~Q;5fl0pqafdtKdO=D< zNK&Zv@b9pV!NfCa=RmSHVK^m?MI^5r{e*q-J;5w0D6o?i zIZDzT*ChTIX>ZM8vbUTEL_+Ul%||O-M;JY^GtT`PYig_`0O2excVU2oG1BgI@G6hN ztMQ#&Jg35Mgio$Ryc6@d2V~{ku@u=S3Aq=p>SPP0FdPf@U?cw(g2f*-(v@2YRbF5*0Z_PN8WIZFILgQ|W1%E3JgLkKAh)8?lcbw;iSH3Mmu(p=R>NnpDD> zXh5j)d(HQ%c5pieAM5vZ(RL~CW0aUnIggxuky#|I#O3c>_4gfhahm5ofX^FZ?mYMz z@|WxS-aC2ECqcexZEE&c!p*G@wii%x1|n+S*R?w^(RP{V!s*6SmqqX?!98f*ppEom z4pgHqJT42xfEl97K+mrR8FMV%kAXmeiou3eXvn_LH0KU`e~Qw^*jP{PuV-OqowbyN z|A-pUE2#Oik(!B3wasAfW)6#t`4?)ebVhA(MK; z8I={tfKY=LfIwg^$L>gXLGeB&9oq|3b`JG%N|2DgLDuyAr2J9MLD&Zd=}wEWQim;;;zwA9Q^L2U z1k!9Om|%1?GC;t{+y&x4j_HLnEjt6!kTAgZeNM0vl4;%;v}wgQ{rFHb?T9o0z!C$< zUsYgZ09QED@)Q(v5r!#<2z^B3GxU#_{4JA-_U|FoI$Ocrtsib2qfseP@+ME{(Mzy4 zhir%#l(H&&vh_$?JWK*x=Tkke=frIMSN4LnC|P&Q8X}Hil@1|taXE!%#X&K2e~w_e zcWjY$NN!V9sTTb+^|-!|$sQe9U|p~T-T@T}2Q+U}V2HZVPZW6wGdMGB&PmJLo}{%x zK4bkI=My}8fL-MZLHYhC4Qxgy4Q*>Ek7u9d4BKs-NW)8N~sejK6+L|^<7+Ln+ zj6;NIWQyg_Q3Wt<+OVAr?P^~Vt#;s8bFl=?Xjy&ZM)N7BfgkB}8T~_7og{_rh1bMQ zZaJJ}K(BE?3PlzDxZ_k{CI=(%W=6sWno3UD#s`2}eV8g?yJSeu+LP$&o&-+0bI;0r zVkTLb2{gl?LsC>Ivaf1jA0a0URWZ)B!+ zRi+QCu)jVU2RfSU_*4E`GqAnL--9Wb+ZQ4@@R2iP+4723?st!8C60L9zz-*ai%PgY zy7?v2?I~owzQg;+;WRdlR0@VJiof5FgPcFm|8D=U$_X`CA_4$#vHY(toyq^2&FIm# ziX~}F_~kDnfPuIK^Q>HXl-@F`X>&7u*4Qc)9U&VAJP?-|lH zXi&fRyOVt-`6YUnFjZR}*P_|(YfK-|k4JnM3EJrUbZNW7)eCAQnh(Y%Ub24mF-i`X-a#;M}Bbn{?7Ze^ja_{P2OnTT;nzwwt6X5uM&^LQf*tT zVBX`_c~kW}PyTzp{GKQhZ(;34Z`)Iu(+ig$!N-UE_MzyqU6HnDycB-8;E#gOvw$Gb zR=m>v!}R99@YK1Id)>^0rys0tVG-+4t@Fk=?HNs~&vBF?`+ej8RmRwYwg}#_X26BP zU*5dZH4xAjK6E;Hb?;GxC2KLXx=Ug9GU*>R*cEgh>Rf4w7w#LPd=699`lqXdtM)7B zW^kd+6R~@UA{%!RF**LFYR9efMPcAhx9iK^F6{nh_nh2r)T3(Ewi)b3udPY#P#=V> z$A{eFJd?c$_kdvgg7$^m2%&JWo2g3drD{Xdihc2W@BY`WNpSh%8*ne=?NDR3Y^AxU z%H0*m^`-t@OnlFq3>ssDdOj3v4^kCl>vd9zRb_3n`MUH>&7hZ`W`iwZcW=*laGv7) zLTAgvGY3(9MO}2L);qt2y8&@Mv*G123_F~F&P(g)2?0XC44kn81epswaYv)p*~T!3 ziK%9HbZYnR@VU8u3`$=OVPt3ozEo`*oTL1}5B-}+il;}tH!1S0`Ami(R99Lo;(V6;Ixzz*AOmaC($ZL? z)uE<%m$#KssU|0B7sIxdc&&<+z2sVq?mwkdSQOAvB^OR;_1s-n|1!}31e(*}iGXE( ze7#C}r~cI8)uKm@E-SHHb@An!_WFY*FW4fE<1Zk)a@DJ9;ghN~tWJL3RG7hhGkx*W zPw`IRlSy+7_M6Ps$Ol1*gJSr0Bgi7hmmb{~EY-Aw_^x6+i--ylb$-_3u6l%%%UrR!{`O0L2^+DSpd*ekkcb zBdDqavXjLdU%_8Wlm>~Sx3qNlRe9PW^Kw^~Y9ti=tVOyAk0zOe9d|}5hn-pIlnV5C z3C1Z!Jj*7`&oL4v_`rLuQ;%|1RaD|*LJZc86^%}?SmQ38v`0IS=H<~9bOlZUE8WfA zz^PYb)5qNaCi(8>q~vgR;PBZhndu%|M!NhEtb*Aj(vxJ$trsLzC1=cjFosko5q89Y zee(*LOhmgV8g~w^Cm`YEI>(9OIJQf?VY%N32AO3}yXt0G6jD1}L3l7L9w=CohM|JcemyQj?Gmnzw=5@&i3dzAfd4x1TO z*kZQ4#V%eCMO4_dwF$F&?0Ki+4qP^+LXV)x9B(PbtQ#Em z{h|Qp!9O#3-_Qan?Xlt3ALf8;-(gujh*pbB-cPi0Pi9AU#1T}6? zO-g*}9jc=Sh`J7k>KjxFN{HCv4(vRAjI3B7t)Bp7Yg!?D$JgaaX}q+cao?M-Lan0K zd(%?t)Iz|H9nK!{;AH-sY2bh&4kRR}bvyn~W*~+CkB&eb&s7B)FUNN^|0PN4krO5;YHj5(~{ts=6s1aNZ*NHk#}DMv3g3BB5QCkqI3 zKTB+7?6zeAOo10^56Jp5r(S$BkK>yeEDVvhv^Y7owY68;9oZd;L?fs@&N{%6HWUJf zgz1}8h~W}#v#=b8HMQA)y&ATx8rlgut{3uoGKx3DavliS@^YVcnmnNp7Y!_&Cn=h` zR6SWVK961ozE$^z)iDgk-5fduAEN|oPEz>-0lIs9kgDbGb7-7A3vXiZu687qKn04w zK?NL$zg*>nFl~AE4Z4a(GvB-#N_$7T+)h%|+HBeh34EPKqNQjd=U>Nog{nN8pz6mw zTQ>ufw&$LGSfaX7D+G#n>#DJxaTo&j7Te^E5L%t;Mi(*7Af^l2$OzF77(;GUh=Uwo z5=X}Dx zPa!WjpV3)rV*-;#+Qqn%TT9x`-bx7RS9xUV4J|jRcFY)7+*o(UgyMmCLSO%q;+`Rh zefoso**~Zja8|R*f~p(7t^?-FU7iTo0Dmw(GX79nr&)?={j~-x*T)*#PNs=+n?o z4AW*zm@e!UQwj`^+2n;{3n}9A+n=4}(?3V!Lr@ZZi8lL0jTAvLW)s}aPz&O^!#ad@Vi%o8#ScOe1UJ&dB=RSIFDw?XRY(bJH8gNL=g_-@xsS9p@;r-vzBi-eAP(SNK8-O4 zf(UG`pfe{#7uq{0 zVAbY|fd07&G$n-t3H1tg+{o(W^AN#-7SmC_;1Ei4W~kXf6vCfqO$bmTxW+6O{y?L1 zPp6)AkK9D_%$(qlXG69h3xII_X2R6pb{($Hf7YzAyy6DZP^`!J(fWMM-tn26jb74g z)lM7TThYI$Wpc>m7$`_BkEv5qBma6~ z%ux1ApC*2VmUvoKC=?u>0>C@w@U?cz3>h^GyAaRrLQ0e9R+iBnzBe##|Es zSQfEk?^91UkbHe{5{3lzsV2#{p65DINI9QmyF?dflcu@Jd^f<^FdxsMMBIcwr+%xcps$(deoH@(SB3FfZa7r(J{b?FYg7-wj@+tzItj6^`lR z&wP4kf0j+@5XQ9pdrtZ|#22UK*#*X`70rF8zY^s1Ct}rQ@o8<=(QM;Q{3#QBRo}jh zTI-Jv>8P>)sreAUp&3VeNsQ&Q1hb%etl3VG5_z}}I$IiZPEJDnHeQN#Y1*H&1+gFL zcW2f8EC8bml(=0Hy_C}(rWax}tiiQMHY<2HkNaHH42)A&+Nkn0tM`?*Qu50opa7E` zl$bgLCc*GN7n{e$2c7h<<`Etp)Lu~6hB~xQbV7WJiAqNcl_yck3ThQHv3=(wayp$J ze@jj5VM0fuUxN3Q=IF=O`aBjHnuEa?zB8^l)@tkI4}Qh=F_Z{H&&1^Xi@7l{8%iMp zf=j=$g@82ZeE^wiW>3CLZpD9OT`F%qTKch=e(^}zw2d)^G<`aka0u6hjbRPz37Lu6 zgy0e{I41znZ1W_3qiBM>))>|Q4@V^R1wK&wy^dMj!f z6I55=yMm^z3-9abcmX4aOIG#uj^fKE#8H*nCS78>OFFwl!Q>37A{)58x_hy|peTVx zKwE3`_x0q+-;meq7GF9K3y6-`V zXAeavWce!x0KLso{)29&erA^C^zhM?RwtwtWl=xKdjU-lsp&nD?=2Br7?75SkAv-L zU$Jn2Bw1}Cqr96@mg?zREh_(E@}LR;5b)W?w_H5Dt{5tNf*K5?=iG$y{2>~7Q`5VZiR$Mi zZ|2opB(D@YAoSo!ej+t`yc{|cC@l>RbMlYMc#!M+Nnn{pvz85*)qogAX*As@{5rC^ z3sy+}!QQqQ{m#ui(Ns!-;1s_6CGRibkYPnXsI`41Ddy%gzRqk9 zcCGn!pn7->jP={p{AtR&R%I<-j|8>cof(6V{1mOLv=wI>KzaxBtG+&~DlQf&)=wqy zp+-^?Da8Hg3Rg{AE=eg>^ejV}{! zjybYq+ci}%goay=f(^a)1~@uJ8{_W7U6i7;<=@%w#Y9mb;D0x9&U0AekN)F>4aI-N ztqA_d{Fb1TlZo^H8sMDKuyM)~L-?)Lb12F5HzWj1G#O)(%qm@SC8N*bNOB#0HJ3X_ zScf>r9jA!+^K!cbAcQ1c@~#|38{4^UvUBV5>hw+kvnXT6L88~Jhek5iq@oKQGD!#h z@ZKk_D!?b@DRw}sPbME~2^y{wK4s2LsIo?8L}W;PnONb4Uy43{2>fTG_C#VBd}=s< zL6eTII17{TFh6CwL1r@8;)t|<yDR#^km!si0doVx`D(3ejYuN!BG2~+2i+i3yg{2L($Hj92!vo zhukHEq6?`9)JgYXL4x4{2s9Crnd^#&9g7rQw;@w_`(=?695pFTomm)X$(-CY54+|$ z8fgu2r+f&S3>=?j*6RT`yBQH^e6jP|hH<`4Hj1mGd|)u&2kO!mrUHJV1+VTr5sHwS zWQdB(AT=1-LTamTroC*BKF5J-1y9!+BZ`Q^Q04LfQn8cCxY|o zD>~avu#sTHUY(}w*i}vtaMEJpI~YI^e6FnwG$)<5o{E#3&7z|My_!E7lV&a+Hd&43ozRbcJDKn1d!JLm3KvpXZ-Lt z76hUGG3$Cg(FDAtoC#`tU}+~1u%;Bij*LU4+W#L>S;?*kW!dzS@}|3xr&*gRgD?A{ zBYR?i4+wpkz4Ni%@rYf1d4>9x1m);#Hb_1lX+(im)3kt-ot}r&$rNDwX|WxY+li5 zM-m7=2e4dir8qC*v31S67cE0*PfUaC1iDPG`DKV}b1|weS0fB*rbiU@$&C*r=uV$i z^2r_?y@o;B_CDkZQD%CJ(fSnX%!!T`Vc{IRrD^HR zrUkWC^KNP6`SGz%mU_9llR=e~8e5FMVjMLQv8H+AmNOW0UdneG+RtJv+kDaeY+HdE zPnNngMG$wlToT2iRhKo;PiHvL?DT$!ISV6)BD5RF1zWm7K7oN+mX31B>h1>eWw#Mf=m+a695;jkQv z%SOT~SI{zeg6_+1RlO$4uT0nu6*G{ru9%o(?DqLJ6b0QjC@TL#5DF}S5d*X36ZMZu z5*^#HC(Cx>>UhzTEQLu(PM%;}dzHy>!>NVBX*N8j&5%jxbJl=_AYoo!A^NudQo+6t zDjy={IG&hL9_Pn)LA(p2!t=DTDDRI&v15V+WklSB{eceWl6-U~mjhfgk6^XX{oxdu z_LeCL73NnP)OgF$4qg$@YaG8TQ%SKBzWp4r+8xV$*FpTj)DeiPgzbfsA|Q@Bup~?U78uvsB75iAyq}NP zHf&$yJruT{h!!!+C(`2UNtcZ$*^?6!2%wr$(C zZCjPL^Gn;Vv~AmVrES}GR#%_1Z}u6d|2_IHuHK7?F=DMX=UmTAzZJ@Yj^_?~`HbV* zkiR!*sA~L#Gx)4C3g5;dPY3J<4G~3H?fbQM#xL|rp!SH*l5Oh*qt^9_e*79n=l#Yi zI*j1QW!1$6^t#hCfQ{N+=rh(+a1%)xtjYpoi`$8qMMNQR-QtUpQEwIpdAM38bg)J^ zCXcfCQ+!R7+6He67P~<4r9V8y9lrU?3HiJtu5)crE6c7;E|8%Ueqa%Z< zi_3okV)Wom#T~Za?-&;RAz3>gW+7XDK*a{*rb`eFAj-;yaLFg1)X+&qlfD^a;MvWk z=@nD?ZTm{c0#Uk&rcJlf6;-msuDT*&UD zDEe0YhLc3rd}ZV)yiyfIe<%u~(^rILUj!D1O*UrC%l>fhFr|-0~LNJ zlDQy=hrMx5L*&gpI*USK4D#Dp6;c1Z>>Yc)n7U-jSCGPdEc~s0!5mZlVR&46Rp`Oh z0WO+dxe-RCp_JIl&&tD~z1r~gqHA|B*BD3?@fOivV3Cb-=d=@;gQLb_5?p4OG&wCN z2~S=CAn^JSNC{WLK&T8t*oi4?mn#FkZoilOg3`FwA3m-wBas?D9Tj~v&C(cWv2Oy+ z(g>j;sV4zP4SXJpX(lFf;Muw{BQzr<$6;MVLB%hTn*YSY2tHCgJY8r4BbxxOjfD^@ zj--@TH%zWWk1;h&&9M2DU=CdifVA>_3x2KgC^z`#_)E?R+#(t|^Whle(>}eO1&jkytZ!9vL#@BSvnPQrm(Sv|jQUlnO#d#*WE$1}3lc9XK zw*{Ynw!054V0KsPf%WqT^-s1}p5M>Jr?;xt-w@j29i#)0gUZ2L8*9EMbZi@vUbCAh3AD~?PtP7RW z2WT07Z`YYA0tqC(JV&PK%}yf6#_Ie!IPf>tJ9h!M3@= zY`}OV5OYgneN{q-Z))6NN7pn+ZJ|JSjxbIo3d`u;X2@&{a~YCMpb1bXHXJE6e#ok; zmR$eJYqMqbZfU-Wsn=$2=8Ygz0+y zJ=-lJyU+3;C+1GPSYo%&HlPg(lDruNE0ocxxodYXW#I%`TE>!+>hECk^z zPfO4{M`|OBL*B!NWNcWd8+hZ`90g=Qga!%bT>r=BHHL@r0i+p@b+#jI{i2dMJF`V0 z71$aNgke4&A>Ai2G=LMJQf zU^~o)pV%UhPeH8s>~nfHJUdXo)shxdLzY8FXAIOA=pm|3C@ML1auf9jtz`eSh-Dx3j`&mhV)iX`a7ZC zI9OD4Qf-%q5nI2`wEie*FJZi(gLzt-Zvr`Vx&1(Pv;Q=Z-yxUnl?b8CtBtsD65>ft z3TUj9X5;fMA6Fue7VcHxR!?|FUFQ4fcab#S$TldJMKdZi`JwZG!kV zE~`C5!$>~m><$*Vw&$dt0KFhW1(amuZPA7v&e~PVte~8^x)i#xXm^JRC*__DR43i+C+&}Vuf3Z zi|v-4up!8akW4y~PCpe_8$qa%bLo5X!_beXsJ^K!GR5bpjOz!o?wPeSs@~D6v@;)SQ1hnPKpff+TxN)S#XLeio7)FZk8!ONQ9}1Z>dvI#1m$QfNg9{gA(v8}u)DlbhET!*|GkU=p6xd{yjHAz%Kka4 z$1nRok<~jCCsBBSk}DLrqey>SXHwJATkn@XM2leNJC^S8$a^jG)2myQILG}=eitWq zEpGg|YRJ<(D@l9I#86O?G>>YZ(t_j39S4tVg|}dl{3!5nRrPdVi(@57w`wUT#pjt3 zv5Yi8`TF0D7vq{a>6gxv@+0&RPI5RR$8u}% z2wE=sZ~)XOhMayP(pO)J^zhIkD|5qs11Q#tOMRHe987|${w$mgUBqT>KVgLC`Uy`L zDH@wwxmvA@1a)|=7+L*AiUBpJC~TUB3)-Vw5}nnC&0k)m`pv$028Qhw%|@GI7TtJ{ zmnf~llV-m!h9o?LPhEi@0JqB1Xsdw3rjZaya>>J;q!bvymL}@2yn+L`HjB3Cliq&S zbH#zaRW~`lBJo&-thoW7KcdsSktR}YeWRcP)Z>Bnn7;W+G zEpvZ%S~ln($LLeuN11VTvEQgyv9lyAa;M$XvtGbic@GQpWh|3j07ElFR&bVgM=n}# z)2T$P+TnD|lc?8FQCQeQa*BfQx2_jw{B74fzG{!*%QiC@mEu-~gkUyfQ@Kwep*s4U z)0#S&--PsM=lz~}p{QEBVaA|rxNKoUs^$<|Nc$PE*_O!6#=hVu38KP_4&|wZbJ+_q zdQl3rdtsSD?#tV0VpCb^HSOiM!QBwNc5wUTHGlJD6}%{uM1jX(#&@-)SsB$_H~$(H z)f{pe0O4y7lKOqFd1!2zwhnO-+}s(4+Dd#>bTWL%iZkaI3x$}ItUW5W4d>0AAVvsk z?xOo!#}Yplc}h2IdX zLG_9GUS^l!IPK3k9t%5sT#B8W6Si3vVe~d&Ot{o)K$kkTrB%V-Z$-@SHtLHHSqhs~ zX{sh}`pYrMwJcTEAlg>T%OPkJ7Ttx9KU8QJ${1$TzsX~c$gjp=c7iSK3mDuCzhA^u z{xpKP$Dh#?8J&FR4aE4KDBpQjIVH#)sy_)^?8O$Eqc6nr1o<^sA%{Hbca^#8OWR=1 z)#i_DU_ZK($5D2ob5!TavI>n$2a`-D$5sV#WUZG3R$zIIeeu9-L0|y~_&9Hm4j{($<+BnwdBA;12(?-D;2EpV+rGg}$1 z0D!D3s@e9yOmIO86biqVjlh0O<|^JEj?Y#@8g6(=!=*1N`R(<7(VWeG;(8BG+~Rd# z_;vUBFF{dmEtp~PQ|J(#%b!LC!g&#{(b|x@*9#ky9f)BPOxp2QmrKJsE(;Ke5J?P& zi;Bo81YXEd>9`5<-aKe@~8EL7Hb}vN@hDGZixxIPQN+OJ%9g)mZ=0D>xO%1xzwF6Y~_%O$#?xY@DShX)iZ@~4HVJce!;1y3$7y)jyb70 zefcr;3jE4qg|bJ5vh=`Nh%nSS7$SzX?Z+imgc_~z@2J#1RcEe zoJqBDb0K?QG~57DSNztm0$7o;+>X6_;{}1&E-7n+?kLjXoT~lZ>gt`pBTp=0j>LQj z!)pgHpLFUj5J&x^@TtvmUv3&hQ!+x~V8Gno#Yn%(h}u?4a0$rv#E% z#}=kl`&L}GMTHl^-6T8fvAi?v8JNQ7HwJK!QjB#TV|>}Is6g0(Qo*zeHqxq*=7OWn zT@}<(mN__3zUs25U;XI}H3l-&n@psXcc?x@N^I+1_}qGT3s4X`n7%b#BPGrdSMqt) zm7bDYZhb7|RJCdJqjAG+c;54p$ukCF5;!2!RR#N5{JzncyNzVCq_|;m8Nt2TKX8bT zE-8%+nZ4yH$8~5ZH{HMA<16jN0}aGAkfA^uK$ito!e_C~C9lYns96VrKE_L%E39X~ zbM@o%W)zF+TYQ!V)ax{!YZ*IVZl^nnMSE~<+K%gly4U;zG47rn8f8Ma4|JO=w+H-D zX>3z+apn1oPK z-qELe?1Vz<#$%$ye1xbm=URJRz~A?Pu$5~?S{ko;m+Z2=WXP*yI0#HP^bY#9EW$V; zsw*w*5vrY~8-4Y+hN<(W9xpC^)|Ck##3L{4y+byp_9~B;TPtc_*+vizsxp^89xS_I zT2GHsDZY@LIqmRa5F@uUtqig{_Fg27uQ%3hEt9lglSf7xjWb+?6^Zlk+rKOQ9CpHL zgd8`wZx|PmEy8_NA8e`I@A?0Cm*TqheSYSj?$SZ|pGXt`CA9o+Ma23)3L+yoo6ei< z&%S*@6Mw?YGz)V|lfyl?|4`g3u(~B>xV@Cb5fY*Nt`CEP06Vtq&OMiWg8d}thXj%i z0Q+V4Imai4;$+YD>T*_WTcyXWEG~_+?T;z$v^iIr|tHE}_SS;eANWgi?#!TwpzdGv9E8sG~$0GG>nGdq+p zgWjjn;S~XqZ0AwM@da94C0_KVLL}-Gv3Aq*yG>ubQv6U;xBp1It zWi#4$*kBL^YIxX*BTA2FBo);N?b1HfxR9?yKG4T8)=KV>OIno%M58Y)4Y&gEmIeCo zM3nQ~&~2Y&gR*4EFtRt%`&{yEjWu276C}L)ZD7V0a&CH4=zKV^CliBrzdY{u5SLAI zbS49B;aXf!Qy8CU-g|Fmn@Z+p<|3S#RmEjk_6=3|V|%`lzXFZx4OZ`t%p}ft59ruj zJs7J7`qO3^OQhx(`q|mnmtOdUko_`I!aE=r@UtDWW<>hnbHlY1mJ`X+PBJ#(WnQE9Nh&w0~8PQqawH?RHay&X`Ft#hK6n&!2%o=1dn z_9+L%BGM4@yIcRCDPiz2o|wp7PNKL+44PYoq>Y-4j^czm2Cfl9q44XlsDOIy8R>@G9TVqgp41WLuYSTsz0l)V!8X6^~OIJ~*JbvRAesyHEuD3XdZ{U_;1 zSq4_6iNO%F3(r+PAK(dGQckypPrNG_X1vw_bytl;orrldzW z(}RN|nEIkFP=y7yw$9LhD(Eg)9YB$@g&VUrJ?%RJS8)E@Tkwt>V>Xs7`t}oPNyAn& zU-B`p?}K!KyTKxZOrXVLR)S+r9RG4H#A!7?&Xyx}?0O<#cji%4-hL=5NF!a4a1pL^ zn{*YX-AmCwylsR?&8?q$02D@4%CmKl>g%%hl#&}&!ajlhl z_coKklLV+&uthq`kmQHN5c+h<^cGNFygY)rIS^nfN!fsHKeE6!3{Qyza)PSC;4x#s zl)N|A1=>K7hpcWknQ`PhI{Yd8=sB5D zJTtc%lfU46W6)L4nhrjcqIKY}Z4It`asm=C-qNYJesISOtO`7|JvW{LGbY`^Z6VNWNP2f!%xlHO2TVFg;*Jr zZUFe2AFzt>B6Nia^B`{dHoP3KEb)4#Ix|-o>%O_QDDBupaTCh#jCSy*iK{k<4Zdcb zWoJM4bRa;2px9=N1!8CRDI2O}dk>@yI`z$5M&QNsfRbY>a2#6zKVyvkGqkv)ALv{) zSkECPHtHfdXj?P{&LA#ZX`0_&i+Uo~k>LId9S^fm`S>CcK1*S+lq{KGa6OG^oUQ77 z)c9jrzCzy{*Z2{$StG?9S2+^!(tf2Fe65Us{tmP_us3veQCEq9U8?H0wa+yIEy`%y zA6^_YnIW;>IComNW&18##t*SyatR8xOu+(r0I?uE(ZZQ!B4HntjL%E)t5;R}nY}I& zS2n5i(csrjM7i>K1ERoI&?>|0bK5FTkDHCvlM3;tui2r4S0jshQ+QqGnI(ZOEboq% zGFBsf>TeC;s!+f2>A7Fv(%JfBj#0tM$C9q8C)0}CmFpNJ8Rum90yAH=tmzb3lg8t*ep6mmms_MEv;`vSKXGTTYCOAL zmj=Z4n;(00Kj;cAmhN^iwrvyT^?ui#1%yRuJdgn~z7ADnD+)erI&NS|Zuwdp(LFyr znzVf;vbnA>LELZHXGREXK_vhn1@3Og#z%` z>{@)X^N|J=@8+%>tRrVl3DsozV#!o1K*3|0K$9FXY5_+;2pNS5wxI8jL*4vrM4J34L*P!^C)ecQUK&X##P@wX9oy@9O>P!b+OJ2|KH@P)Z+tsWQncr7aSIc{T2xdp==2q&g`^25v{q zRu~udkVY(5Bp=bLE|iBI_828p*kye(x9CG#QWgbiHAgp&JV;I})I4Pflw*phlH}a3 zg^o$MMWn@q?=m-_)U$(u7+DZVb@|yM_p|jG%C4{sb#+PC6RE+8Y|VKa1SEF;R4|sW zKmF*}0l|Dv6#FLd6kpoo*Q8w_QqH>LFS5aN*u%nzcHMg&rewuT&%>qqcI(}=*59$u zWak=JXKL!@#R(=sJLOWbL0zj5jLq7o(?7n6!$3iHe^#FR5a7eE-VVE>^ZWy)%2>jr znsRb>McLaAmwquxO8)MP9vDWxbB;O#+8DD5G>7|;U0;genQ zy1#eX;V0SL=MyXYVw;*UlXK!uaqcSrq^<4Fy6$$aW4H0xclbz&GGF%;dgS!w!9h&K zMG1JYUUc|SNuzd@gnlmuy?Np7D9-6?$VSEN&L=hNb>uF1pWxkxQt$y3-MjTiq1W7i ziWFwce9&vfEyIR(d2G8YkhbUWp*omy?M%HCxq%|tSa$Oj}U>iWY>ZmibYM+SDcGc_?oH0U=IXv>*=Waz#v?dvrs|hGScD%;DUFmBxEWH9X8iXDP>5Nzw4@b_?S#pt~IlxtKsh8 zLf?0p^0#d<-#WQa2Vech7s!8qpBsO~S3;nGfYb>7)0UgcFGmOG|2ZL5gKeXDIQqid zXM#bzuAQMNs3IaptX@H+26l5n6H8~Rm?Vv_XdQ37=>FGxmXF_5v9Z5r9%k@Q#-rcu zyHStU?KM7O(QorNIC?ByO^dNHmqCo@M`an!v8L=)GU_#uI$6Ni11Kax;&N00Z?=US z^BDLbc$Vo;2?NXT#i)W9BR`!ISUDCwGpui&G~C}y%kw)1VT*j!PFf8~#dPLb?6n_d zJ%q0>=5C2BBCu))?DX1ltN#G6&2Z8KbHGJ5NjqjetZY(~EbCX_J>h$*CK_qrIR4zc zyyLlujFy3-P6L9Uhx^xe7s$R6f`VJS)ZOSG4^wx>5nzMqX65k^XU?v%G^zESBAldX z5JEhpBYY7-n3Hd5JAP(!3T&*ZPGH^smyU)bv$zwU`ui$(J<-=>v$(CGBko>ejZl6v zqi=1^!_Y(8*-Sr}8U`*d^2Pdgf{Q`AMc}sM$)W`Imbo&^ zR7{T(ePU%xS|Rp+_5p7Jejjy1q@4tDbs)M9Dww>>qn4?*F%N9Q1wvfP7ra2}#sgmM zlU5&N>)L^Rjk=i>@AwliEQa-zsd3uaAjt57=F$_Hn9`~Glvz=r)V9bxeCl_TeA(d! zrp0N)ro~mCW%H(Q9j7i0shuHd+7}&n**QglMGX?b%B14O=zGgYC_(fylSeHzgHVyd z#pbXZUlUZa50p%D%MbIQ*~#2qGt7549%}f8?5}vlf}D<_7p+sqKWp@%6&y9iGGE2f+9latJNXSpT`4*GR#5$_4TwQ<<@eRr4*OYWR^n74Pb~k z5TY?@IVleqKMIhmfps%sNXwZjX$J%2@Q$*v8l|D6#t@N=P}LCmc(9>Uja5}qC#cVf z#5^^LLwGo9N%86s5KOEbq{H)%bYSd@p+tePHKq=s@8?wtG@NJGCojhGlfTb=M22YM ztH9u)fWaBM%(s}LcyCNbV7%bjeORE-*H`04=OK|$XHHfoSG7mdUfytr7BAYB6wf(5 z%kxKDmnLF{P|VNvxI%=+7ufx*e1ekXaFAUMwOQy#>-FOM z9o=V__)8Z&PQSvA2D0+)xdvg#NnRaO4KIV}_jXZFs94s5+J zQ_x0dMUUnVwe6~^l9>Bkd?u&PSz3;;T>qk=!XPYZq=8veyKN25UgB^c)IpcvRvsx- ze#`+}0gRtKu#PNpu6j6K*uOftgf{OlX*AtCtfbTf9Do$`It9eYci(F_5urZe-K=pA`&-A=5+TfJDt_|;ls2~Ch zheEHgAbvO}T|yE@3BFo|kLq1rx%p?#H4a&2+j3UfUXe_4+Q_sx$Eg|c(mbfPKB{Ss%M^(s=s;aN6yB)(FUg7s}O`5Rk^`C z#jA)Fs4Q+b)!00`I06UF-WhQu^5i{uMKCI4Z2J!p(Uc0RQW)FCyndaqbqr&owS~wi zoj?t$I)9`(VCF~#`NfBH1Gt2T=3B(b>vp4RhGnxGf$Rn-{ykUsv(qN~;M!aO%Kom| zARoi!2ClGV9?vA3Ka(uMsTBN#R`wQTOB|1&!9$!4R~`bl0fpc4Az>1D+Jz!?cb*9_ zSK13IV{T(guZ(f% zsa67ca93l5H_Kznp03AwP_tMA5CxhcP9ft^x|B+L6?LC?2Y>L|rg@4RI#QhYgigGz zhjK5*6}K~r*;`X9?7Zua@13_@PK^~v?;P%vvbz6wwm$?jilAzhZaW^hv<~bdp6&H`Hcy z0oHq58g#7a{Vg1L^{?IyO7rc`NOwmsvJ=SR2Ohda2h`7u@7q$Hx8tKl$(4`l!|nJRl%% zqyLouR&#K)GW{R(-$#1e&bXYZy|1cI#eBrGl-e3E^Wz7ggG->kUTYdY3-Qe=q{13>q_{7zkI1$*`A zHR1{Qy-?(%Lj+?nWNIeM6m$Qmwvwn;41eW0j+2Tz;V{@}xpMUh*JCKtqaANj2R!99MxK3#odsyO14X*xqk zKCN!VynSTb4|;hnHzA4MTDR!ay3^a!occ7*)))}^?0$dKyRzMMr(JaNhGdcvo$*Po;cwj<|PmHf3n28>c6W#nq)Vdn7d)pDU- z@as3DXC}BSDS6tK%m`^ReEru>34!{A9gJ#tRCnOB1vboWc3`-Qz&-yBY9rTa%0+U& zSHTkf%#F&J(#XdmS*T2*FU8lqYcBKp&{d&IPnKSFpB!L3ZO*l${0TlzJ97-PIw-|e z!takWz4d0X!kg=anGXzoLWyg#V#$GH;tG)>F+Q_ZU+y%F*DWjWsWvvD=EBA!p36h~ zPDx1|?`Fs%Hz`b7E~^+r^eyhb?V}4ZSelo2_T}{G{;iOGHs2$^18!8v3O)+kpgC)< zjbJMzjVYG`lM%4&6;!e&!fei`5Kq>W_T3*stsR0HX> zb>dl#D5*`6KdX6_u|N3@u@Q*C!*z)WDENrm#sw>RDM7!yTz#)S^&&r|_t;yM_zUSZ z9Lg^qrNDO1nCPbns!9u@pDmmS4KYk8GH5WXX@<15Jqsjw2~uQ_q~o}tvrbtGkr|N~ z(lO&=6!bS3YNu)9M^D>@RK_zT6+@klBJ9X@d0yX8rM6kW;v%u&E^Xa%9~PpA+Fh{? ze89xhtiLDX%}F!BHtb?pcMSbY)rHUe^o0zJLrSkSA-05iKzk!z$+GVEQq7*o;oL^W zSrV6Bu@OEuHcVNH+OhMPVWg(e!tYc1+J2ID!(lcoE)AtP3>pLSyw-!G?*QQyowp66 zqDRI3TZpuYp$bBI&Gsazz*Wx(tMeO$rDj=V`3rr{W1q|ky3ovCqV<_~ zu45Jb0tM%Ipd2tPMp_qvd4A04;Rk~I>M>CGNYbRtBt3K#Quq7{(cDr#vgjp_QHxFM z8cenagztY^IY3|_(x{;sl+szVRE%fVhO81 z7$em$&!C1(r+i@WVi%y;(a)#ZdR8I`hoG^Ih@8OwZ}kunA(32jB?<%l1yN^G>rInw zq-JRArc9>fz^qh`Jv1W4Ns~!%rIc`nM%YTYt~TxLF}^mT;)4?$bgQf{7=ojZF|> zeL(PFz%!)p8_vMAC`+YwcUGxMoU4?#(w5xQ*^0npM0ZFM|4%H}IGQ9(=KlrH77%ih zX7x$!(+-Jwd-xOa6xnx+{OWQr{{b7YY}Y;Y&|SyY@415tVa%^XtjOSX-V`$Z8gZaT z=F7uJ@4}JDHM~$ZR3^%vOB~^OGCw+R355qL`*=5$PV+*ymGNiA^f@fr+tAE`y96hX z0(9P5m?$gBy-~Hu_cK!<6FYGz6nK z_!5eD$Z=~}4s@;O3fu>+zSw?uU&MM;3=>a^MZukqmJ9Z)FA}!2fqw46App`T@uRmG)fHl zRz@KC+Jbd2XFChCVN>782`lCGkvKy!Isy%2Qw{L)RYI!=cE{8>eiF+I;=Qa;`rACo zEG8Wu2`5WYO_8OAcf=N_&bde0k%Z-aBP%0hN5qiw*%+ON-{9uYd#Q<$+%9VWS&T(! zGwR-km24-xqIAn%6%akv!n~Fe3eR5S3rSy5>Iuc0Zj2OAk*~eUMb(P20&Go?I`^>B z@sU1;n;4TYYH02qWc{ag684?ZjIh5c4J?f#5pbrM&Mz>!j+TAb`a&ok$cGF>x)Pxh z)~bG!Xkg{JBf)!N{QdJSsYB%s^az-Xpp|#1hN;A_#Jf?R4yiv+-yXQMLHEoL)@&1M z&UL8kBXea|@Gb0a+bW_4o73D#&n|$?7yW%(jTqlkGn{msO(%z#9%MY=3dt1RoXBG! z?cq!~z@8&(%CQ2Rld23O2mF^#)Nl(@ZFR2SLrVV@z-E`CG2L?DVIMJ<=C&ywi|_7+ zzMlIihj3!MC#_f`9<;c&QCAz+QGD=_H0Za6cXRtF=%3oI>HODF6~*%JM4&f#RLi|I$!1?^>P42=)(@abjg5Ap^OBe39p_+l+i{E zDOOvpsW5Hlmzq2hdo1uW8Tf;=)GE6fdl1@K`tEK5eZO<$FkHrka;N$JS~{u>7Rr4( zxW*e$YjMz}vi?U$L49rO)>xo@Lz%@VYmrrCs(kC#`b*N?MvYY|jD+QQCOXUc+zN#W z<~L;d3?uStEI>o22rcMTDHgrC^Kbo#juRg@3O(A0-Vywu4|9mBc!NL1YtYxVig9@| zz-)~!#7SOmc!I=A86SyBA;OGs*#!{p0 zp93eV!#3uOtRH2IH#o}*talj92QZMR@Dj5d_gN{bRe-v#>463c_MN42tnF>A%V(L+ z21QEgYC-4EeLz+#ij4+utd^FQfDI5eIL*QFn*sGrbW?t%%;gOZ_h?!-`G&?;dKpH7 zhdul1k*LUf2|MZ41X}PU`+AW}-GCVGczwrv+J@~gt}S)YCV%FI3Cv^(tCn)3b31>? z1ruNZz?*je$l;UpHAH1Y$!o8!GKkjZ>yOA-Wco$9n$A~-9>O_L^=Q!SNK%AdRlg7? zMqoF~bv}!ougf3fsdHQsD=L3A@Mn*aC#Tu|suWuLi*#t`~ zw`L}uy+5AL1BLYZt>zX$D80BOIk5twn|jv|ct0R8rY+aBYe4fuowd=1gBQ zL;srzQkKDJH_rby-v8NU5HF|>+pcoa1e`xhJT(ePU!I#20V)w+Y)*A1e_U#awoxDt z2{nSiI6QgQ;1oNszm0N!zbQu3Xo@y%-F*{(ylmz!USezR>Ki&VU!I4boN(&kvZqzu z)^Qf?q+}t}1v{*rx<8pxR;8}GdF;gZ#`p$uAH@gK*QiAK*3ci*1uJDb1dRDqdNyLq zM~R5VJ0MTCg$C4Q{9EbdHz=1Anh7SH4vJ{!`)iJ5C+<_OZNGQ8mqle9H3Hb>vY-)9 z+07+5G}juI?$UsV1iYlSF2E0!cub2u)mB`e+t8-5Xsu6t*}zIWuc)X8)U$U9(=f{X zET;fr{0utVNZd+};{vqn3_eZns$GsHo?3lv8XOY0qEj>~TF*~+jgIS>|K3MxFI?T< z&uem1=;e8=>4@Q~?R5s17a;7_2M6x#jSS&Dz2>4PWl4y>D$vtr)jyB`s@(>1s#X)H zhsl_hlk@nAGCSxh)ydXE+MHJ(ahZ8@+tBrT4U7k?=dG!tKg1AahBk);gr5xm-sdG! z*6Ie}{seU6Wh@WQgDet$OD#l34VsbC9N6@pi_dP6BW4T;8lu#&T{HnpTYNeO-{?b9_n*ma)=YHB09J(IP?XY>RVOdxW_o|kaP|8S5?Zn zsL|qMs-^R(J85r~^%LO;+S;I)$I~AmXQ8Dm4O2(Vzu1oci=8C3!*Qz92^f*=3u>n> z^(LfvS^7h~!PaP1opgt&GM+(IW$cyaZrVf|xvV6;a>JB>L&N1)nlb}p6}P<_%^(T& zEYgY&AFXnD{Z>;gt-03~dQDnvnvYwI`Bfq1^|czc6TW1}Av$oCr-uXm$Ozs;xKul{ z(6Q8ghz3$FczIEQ-Jcn}%dx~#A|m#%CY4B5DBM9IVH)19Yu2rHjFElHo4VHN^NyOUTv5~l8=DQ<);!)pGs->P*4(Q0_1 zxPjl!Qv4l+XH?sgw~1NkdBU@lC^`_8_-|Chh^2OzRd&)0dRdXsAWOwB;V#nbFi|sB z-zEj^7pJ^?glT44tA%|d_OiP{P8Ix#> zzsSs30;C*1BY2?@)g^o_wW$Q$YRt6Fv{N|UJ=Xk4uj!Biw9M5sB6zY)w z(BqzHH<0ljNkC0=K0vO)e)Xa%X~>ahB`q%E_MEfnx4c}gXtZ&XpH56Szk`-KL*;+= zN}*O26aB5TpI27=p_Hjf+RY7U!b{%uy@4ZBVH~p?D!eR!ryP#p_ZLd7(Zk0Hb-;vV-C zho|GOaEHC@TmUxLTji0u%8|$VbWlpP{9Z2}ru07BG<)8O|`lr1J2oTU#!CF&i{5Fn51n>tzqly7)z zSCl3il%;{YtTSe-Gok^f@@!W`(PyhC-t3|jiNr^R^;oT5P7~%|@~$0%JcQzK72OG( z)Xzi)5}TA=HDSsQ$Pd0&6;G7{VpWpDz!sROxa#&#+hdN+4)f|0H0pyinWx~W+nm+3 zu-1~on$7JRXF1bCqJ99eXg5augGkjl&|y5xqZmed7<_!RX*@$lths=o2;usJ6;K^3 zHj^>9mHJUVQ>*%&*?LFHjOR&DEYaaG=wH{;63V9@NnW_MQB0J?zutDKL4^InSJO-5R)BW-9dir+u zH^H$Qw{7iL-^2=9v)2Q9_&dc@vJUKQ4KI)NTd5>`VLGW!vIe4pL0*y%o-A>sK6a8{ zj1}W8Wa7z1YvA+X*sflhmESY_9|m6zadh3?$mYlGtp!{}QA!KX@t=D&$JF3knTPk- zBiez|go)%TfB&|R&w0r&o_EjhG0Kl$;ZS{z6Yy&lB6BuCLz(|>*&74!UML%Qe>+ci z>qV*sK6gFDEB05g>iUMj>*Jz8Zd#6a{z(lw9Rj?KJf0~EKWg3jmi%>zB~1wYM9)l) zZ$Yw`NmRH5IWnKPm1z=(e!AN>e>xS=FX9o6!<0klQPZ;+LR3{I{|ZnOuhoadj)gt* zzub@RL)I>D(P@22MrVEHhHk$_u6WdXy1x$E@W^2yAk}%HDLdJu^cy;g0^?!A(bPTB znx7xohZ?oypCI}%zqobl%S~6-{d~Pi&U$e8fY!5nw3a2c99oVns%-eK+2}!3DBSM? z&RRHZ_h8ER2(F-#$?e9qJ=3w4>MGU#218e;TCeH|v|n{tQ>@_O|E@(Coas4FSn6Dj zIgd$y>9^z67+_})Z^hC0yFInc(P9U--$%k`O{O{)*g63TV~iyt!;_p@vF*oPD}g1- z2Rx+}7IOa7=dpXEPKj+vMc6NJ>Hd22;-2Tr%nv$Tx;7L~7&f0pUl2Xx5ZMy> zfz6%Bxp316=3~>XC!mZ+8olNx9<<-^Xj+Ff0moI)k5>FL@H zXwJRXY`?cqnbBXWxYAV$G_uZtE;~&;WB<5&9f20}Z)$ z=@`itqfo5>3n6LCqMZ~?)tT7Ma|qSqx1GdFddC2W5b5JQPtgb-YMFT+q#H>DGttDO_)UZ|Vmp-Y+0V-taBLpoN<(2T=l1w+*4-FwUnDQJ&A%vJvFC`W<0x`vyAJ@Uu+w`Z6KL6Cj}>b2vw9%bF>QI-?&3qw6xxx z+vM&LeI;^aaz)bcND!G~X~wlX?1En$4q`*6s{L2p#9o>y)1ZYz1Xz?+AH~R#v_71PZHS$$d2-SBc_L zW-7_ERZtg9+g=2&@_e}d{MvjiOg}^p)#n(1eXjpO!iSS1n*ooMR!k#1I(EGgJJp80 z>PtTCzSN$}bA$p(>e@L{jZ9IU8;6A(k06K;N_N2lKuC=I3Y>n0YDFW(CNV&8ElM=} z{Ksu<>?gYz`pk8#f55n(bxZJNuhN+O+mKfcCc_vtRRQ4E&zd)&$x@zM=NfR~S$EIh%PEU}@*1SuIZg4n7Q-%o%nRLQwGp`k@(i%k}(>MqD6 zH75(I{&iqN`FCz^sgqP()=~)bUJ@nx2_;jVu?y(#gT5c&AJ5SsMzk9e?`$00B^b3^ zkAg>8s#H%oiP7jyK9$Kc&29W?k9Q0?Gh+SuRfv2+tM=X1?a3_=SUlTTvJmPN=*Z-pIoq{uo-*xTSwllFYv2EM7ZQHhO+qP{dZ){9B$z*5kv$gA6 z>tEkNpLTUs_pcw_&vl~}t8J(-#=Q(m4m@5@Ebzo3`15@wbv2@=F&DoK$WaN0?AO5~ zg|gO=Wm#$y_}*o0BRrhf8Fq*9HbYx2ANe&+i+ExM z+B8rOz!`CCN-jn#m0tCtk&+K~SLn3xz`i!?$Pg5fH?VNKg?#ZNLXpPd8@jRd|Cz~L zoN5a1Wy>L|?`Uakx_jR@!Zn~Y$D`X9`Jrux_Vb6+oYy%v`$?cNXYlj zkm~!^Zm+8F8oghi=9OEdwyLJFIkF9Gm4=t7Sge;^2YuX`5->KJETN8{+$24s{;RuM zE%^jb;{Q8b{CWR&(5@f%2>3_Xk9cX0JyVyzE1gMYJ@%*2t%Ob1?XR8Ir<-~^6WDWZ zG*8Bw6>{Bd9ofl^m93XLRn)2$3MKQU>Ohr*n;iIZd0ZxZwip$9Sd4MdJYW3_MCg4D zaXGFOO;NqJZ{A&&eQ^fIW*cN-r%wS&=xUjk6n@@6!aCVp(W~CY9KleIW?AAEuOGO? zU+Pu36`zh}e5mlFKfwqK= zQ0*#}x)9Q|5F~}7gVHqqyFq|8?{QXMDk#A-Vbytv-El;3Ka_l(iOns$r37R1fx4af zKIi_gkNMrVoc>DAoP)G-#y~{M*oqei0oF_^TSwZIb*57#21Z$^_W0gnnQkxN|2WJ9 zZUU6bNlil(KDkJq3900OsSuALPusR)B4U)k{e_dU_!nI$`e>^hMR1BTxUMalw5*Ms z)xtJzZ>=0fst_)dC&s>~i^uCf-X>W+7pT7f+Ndo+7cu$E7U_1DuL>MRzSU4Av``_R zutP-uOD2Jed7$^9+wGqrM2xV&q={%|#QjoXh%BB!VXkjv7;_I^w9X>Phln>lrv<3V zyge!B7FmIr0jv+l9GV4u5$b>|RlITVG#~^9cuJXdDu^9GAI5otW2_4#FOaN@S;TPn zoqLoA#)y&N`-PD4}*qDr2F8^ou<1gm$pl!+9$rHK)qHahphfGt=GqWc!+HC_~)XZSw( z{S54;OGO_2)bBpQaAkpU8mHS3xB&!g>BlT67`lQ)A(qHWHgc3LnAQ)Tf~ze`T$=N7 zl+)tqrn$u=Oyyj+4$loZa-2}x7fuF|+;Z|P-w|;f#unuZCnJFDTWL-pLpgd5<7IH{ zwAT9St@-y>kE8X|+Ja&qARR)gHqucS)yf$Y#R+lcvzTaichZ;1mr0|)uhFx(-9*Ye zJ-E_&PnIB-kfF1J0V+)|tgmm6J3J?dY1+{{XE|oZMik^}(D^gxfHIQyEMph9oRUB4W+5czXng zgeWCfXq9RQ)LkbB`z1JM>&^%szdbk8b^c0J8Ce2JWl&gQc*`+K5782@1<{V$t{3{w zBNj50$!$U08KHDu0atrLEk;k%CFvTOU(?1NP=h#|kZ{2t#@ks!EoNsXK%G7?r-cZJ zbI{b8%S%S34Rk9^R!@l6vTzs2K}-Nx_GdsLy`^%+d-;2K0kHqkUch5}>XcgbHAwq& zc6uVLSDcqX)VsM|!{G$sP(hXvH20Waj{ypG^>b7y`k2MV$NJkGzE*)D_nlk=-6tChOXT?)AoHZKcNSs=a{Zfz6%Wvucvg7dleclk+r2>K?WTHhc;%-&ZhK)R@&R(z-f>Idg&o`r{I zxvP7L_L{O4VfNvnSxa;{M^*0Cl0CzwA6~5ShZbJ1i^;(cD>on33h+!#{6-|ZTF}Z5 zEb$)9qVN6j;wr+Z2t*Fyj~=VRtG0C9%+6|R;iI9*Gqn=C*f|H4_FFR9w^UO0A;y8B z5PscQ_8g5*iy)~hfjLzbTb1*_FiSZLiD0)Pel6}VezW~(;)OxPphTrp~h3+ zfueV!C~b3mc!%Kax6-f^irt9`De6#8UhtcuneP2Pqk3=MT1I~!RhH?O-3g~g3-9%; zmUw-0c+IEfYbnl|)Ed07?H%p6N=4(4$?iplelyaTF~8^!=aSd^5XaxhDex5OVZJkB78G{J!`b|51v|saR`=wGFDDRhCvXLgnyWNl#@hV;#4k<`*-Zeq zEr9ktxX0QufsXIPjkp0K8$^KzB!ssYp0QfDlC|=nD91i*w02CYBb(+5e|4xeCl4Ii zVf}Km=i+NTPCbBFy^Ba!=#5?JdeIQR$5hT60d|6NUa_6QbNU02??^;pS$H$<){VD7 zTf}k0M1QM^3JC9X?%gVpyBw7;i#Vu6fFCQtFa=C@V8Z&h-^qcP#?ZAS#BK|pDlnCG0F@wvc3}-GH!oB z4d)ygsBHP-S5FC+EBI>A7`X-jwQe;S!V0(!UIBu=F>Z?+Q_6q=s@P1VqnMXMb%%P1 ztTDY?GU3i+RF3Dcp6(wA-GiGBkkBqF1hjKQprCoDX3DsZ+-#EqX@wgp^K{`*A~CLx z*^~(Q#ehy94Dcg7%@2upBE;VL%+l3)hwy8WQ1;>7go$My5&$h14BgOholT_|#^(si zvmQNGQbdGTz%+56Fq6H?XtRyET#Bn4oi5l5yK8EdVI6EWWdpwMRa+;k?c74q2G8ZmdzeDCJLg=VEtb2z;~EW{81^9Q|yCKM-PoVY>1U9Y@s8L}O1 zB4%SnCE|E(W8?{Aq-)nvElLM6aB3%n8NQk>;-FZK2AxX2u?dLOy7r%g+Q!s zv_;e1%juqi^R3$TzU}GKbU6oq#($yP+S6txEj~PUcr96riKO7wI=`-;^ubCN<+etb zAH?+y$L}tpb*cLqvj~A2AI&a&yTdeZ?hie9-Bz8{8(bi3eTe%Y#`X^AR(Ip$$CO2Lu&rg; ztmfirrotFiGn>>xeLE|E1|Q$xD735HDBS<@vs%x|o{WwMQ5>@Z>tFf?UP zM%NNmD*kHdXxM@{Xof?k^YraIDg_o@+`VcORU=dIFps#MoK=+QE0ZPiXG2vht($|( zc3x6rWlGY@(WDOj=k&IRCw*=q{Jkk;O9yHGO(wkgO}k-#3|X#cvD>yS=)oWMw!jWq36tHr zWY|2{dJe@aVvL-73=0dIikwh!kqYr)%hSPBU;hDEPB!c}k7JM@JNx>rm5x|fPcH}A z+ecqo66a;#O(0_h%}&-Gz$`20D?KDR9}AuX5m`iTKshA*YeB#qGu`*egyUNOSzzkq z;7{=8w@MXus9_6Tx7T9l7Qr(~AGK`0amH`ah}-L`NQM^MnlnnlLWRNhQ4!w4y7(oc zw$h^a+3a=juBC;)lRUUFe0ecm0m}|78E_6-m1wS9Qg;~>L!@n#+wU0?+=6;8q*LrI z0Zp7ZaO5Zk>>5kqwXEe^i=- zUx*xmC(U`vB&siQ8;}wo82)=m!&O$Sb^2L_QUm|r$N@(7_AbsYPKFNuDX7J+49bE0 zMgm&!?k~#2KoJV2Ai)t90S6@l4jz}aG%u92wB}9?c(DIO^`8mCu{V2J%WZGozPQ%e z4UsjFfvW2XlkO9QyHJW~9hB8`P2G-e(h7UO(y?RHM=i_H_vZTAOT;CK5=O4Ll zNws$POdujZHl2m!tUZC5qJ#Aqpf|=zNk!IzE>_e4@CkI?w*HOD3~QfbVHpdwQ;k0J z;>+e$y=y{PM$O$3s;&zMc9XrSzNZUCI+LI&-GDPdp-+)~M6~8oV%*WV)(#d9@{IC*7^xMHmq-#0dVT2dg+@?2 zNjf?4U`30RE%Abq$;7JE?uMXc%LTZC1*HW3?vw`VQVs8!D-q_rU93!a6{O6^<$e7Y zSGvn#>|7T&O^M(xo^(Tk+l({8W;2~iAk0X25-z#dww}t^u$#t}xQO=yZlmHKu%GM) z`mfjd|4rP){U0+F2^SZK{~x@~H&mB*QcxhEQAi*l;{W}of)1Ad=@Xe%y|Z6tMEc6> zKMEn|6qH>iPEe4}l)@ZYut285QKCvr=ZGb04Zd*^HvZ;5E4f*Eyh25JS0H=o_I972 zUpFr|)#H*w5+GJ)3)sm}LJTq(mDSUAMeRgr5~7w1oz$!}Y49`|WWb{TX$)iprZ9Wp z&zAjrqgHkq1uKym1m9=${~T3mImlvGjZ|HCR7yir`K^vSii(OEsP#azEja&PWf%%$`jmNCCWBDN zO&No-LYk`tRG+4-Z*-$s8YvDo-kWzGwCYe~(Hb%9_IjAvL3&i}AuEg2UGo0I*lYXl zkGDGo@K~Xhb$i@{0n3jyH-0ybdE-cs4t#9BtsXjZZWe2k!N}UoZ6SA4O ztQ-`3il(G>HkK|tIJqU7Z%)zAybD1zy^v(i40?IRqB^z}l`iLkXx|+=J0@@b+-^i( zk9WHlh#S+4F!-NDXFs&zVr?wiGs=aHD5l)JBkHSAq(kgJBoY!(0Uh)3r2l&n{X{66 zZQJc4uR(5S2c2^QGr>=KX_NtA!Z%^UqjsK&i01sc0jB6jZZ{o!%x8J zb-A2G@6QigH2GxX8E$`?yrvDg8^>K7}y;-~VE1W6^iUbNK$=X-dyjBaf$v zRh(r(R#j|4-2R6-5_N%s+R(9f?O1;YA7A%n^j>al-$7NAtId2*qxX5vl$SrxF!VP2 zHar(|O-@zy2a2{t(4+qqbDYiHpx$42+wx@OA5`FSmn7D8PuuQT1Vr(_pFashI};mIr~kN$aHDB!zdeEc z?^eGNID`azA}zN}?23CE{?*!88F@wI2T)Tt(Y{tJ-q%H{E z;3ZtRZ2s~I84oe_F1GjaP1u(TAO*$~x9q)7l1D+nZQz8z6BWyk*SH=|;^yPAQNR)J z+4tjOWomgEn<2grDMFP-vmjuC)%u1SOdc`$vq49~)&X!NdZgku)$({tLq>XjHYfK$ zt6A0w^vDwOM5n)}1!N1L-an7)jKQF(B+jF&Gnu4DNq$WR!3!uT+|$Ye!j@DL$yz4$ za`TvKvq67B5C>(ATGg`YQB5o~1r3IzlPhamyKj~GX&;1fMS&r_2OoSKeSAD#4v#c{ z4rFin{5grFFcA@Z6>x+?>*LL~J!9;c&7BA$j-E{&^PC=F$}Dg7WlbWbgWe!-8_ky4 zPFh_`TwL0o8bab_K6ID2{^#+C$D$unIN=RihU)sR+I64|B@#cQ_j`z>vLeZO5MDRp|X zNC4X+Bm+tYX)~oZyxPF8W)0zh+R0wLRS z`1|88jO7c7v{Bu92SeU2&ByV0twWAN`Vkb_=2UqlPeBpv(3jyM0Z|D?94SC8#T+f< z#mj;}`Q*OE&ac$I$ovG(Mf(uR2(o z3BgQYD8iByMkj+GktIGpmo^_=@%XBDvhv3zBf!dOweZ8G(LZ*OczXAvt+BupDK**X7I9#} zvJmbt!CaA1zB#laEr)d(;eo5H#zSaJkAjM-&+ep1t^_cuRzP^ev;f~w#;ct2Kae3{_>a9c(6#D=@@nwoYe}DW-N;LLHIqa%g0GU+HxL)ys}Q7b~i! zh4G1xu~%gp+3sbL$%1X47fCtB2n>h=;ixGH_-SXv;=7fQ?L=J53PWV{UR&otiAd&~ zZL#KNn%s$FRfV~-6WRInUD===ObD`OT@|+NWHKejZpvHGfgjU&EpsE3noejz)5AW6 zkH25X_umT!@$q5+J7Vx}IYKhLsw{E&a&7Sl-YBDh6G#>@A;Iqv{{-xgcJ?$+y$B1L zzPjHBkHE)b^HErtS0AD#Z50snVd=0E4;&n`9`tehV2K0iD1WpV}*sDIxpHsq( z))HZ{$pbq$1(U!|s5{II&JRXYL}Qc#*9Ep+Vc=i}4BOw3G9(9RN$1DKXV|gaG>kpt zq4IOis~M-8x*SD{QGC!zI`Lr!`rQ-Z;5XhX*_dY;9V$Tm(P1vq6jpwBStt=ptuyt& z#&(?$ITQi54y^IY(=07Hr+80SyU^HEnvIS4Fj};;N>y57zBzi! zp$MyD<~zW%@d5_s zZj@iRksN?PAk7Ba=nwJmrgzM#zw$JI>cAaOk}BGWM`lUf^KlbZ(wT0BZTiXAsMlZt zDqeWIVxZAx2JuL;?0q+&`S5*QjnDha0Y$VCj@P!dn?c?H=oI08+L#K+RgYj1`+G*j z5^FI`T67z{5_~`z%Dx9gkQunB{Rwd)uCp9<7siSc+o~w-j0`2ub6#YG=Zmi$oicul zZ;)6XIr6!-Lq97PKJa&xc$N(~R5BTc%F5|nkI#xBn5)~|anbYfxkbxck2EbSzeU(# zixK4C#mJWhP?q6NQ=~PHX$nC3}m87fJy4zaq&;E zxYKP#%1wX1_b$He&%7FM^u!`; zW_a)awJSQqK3O{dDbrFh|6{2pYH9MHO0``L+xYE{|H={vImF0xZKu0A!77i^?RxlD z2K3m@(h2JH#1qJ-Fx1k}lD2BR)4%tym6FK%ZG0^FRiPU&COr>l?lZ$L5t3w;sx!$Q zjpMaUNJNI{mUSSFlIynYdRV_0%>q#);bk>!_;dkBh=Rw8g6D5-tZ<+>&`Dx>KDkOS zBG`^aZndY1>@HZVod$t4HjUDhce>bj)~x)oI>;l)n;R_mHuQMpF2ls~jt8rL^~fGL z@h;+C$I?=p2pEjIpilCF;+bot_FM&`byJX>HLLhzIk|#m{aR|n&+D-8shBh^rAg2} zMg1@w3=4Zpj1f(7@q}DH#nvf}ShiM=y|L8TUr7)#URtN%eD^TIbqlP^lB4AL zA8qWDc!IVRaDaL)E;Gv#*`N`K?yevFT`sxj_Jz(t;0h|lw!o((qKbq!ZxJWew-o3t zX=K<740 z0M}w0WQl_LVw^s)`N;3Y4g+a;Ro-jgJsO}1#dR0bp_w{bBvRTrA~R<(&>-kTKXI5G zj)PpA=$+E?@F~4xK(@H6o)0fWOGgJ^f?BE1SJL9O!Yv_}8ft_QUfLNIxk5HDgA~jW zarAzub}Lgb%_$W2e)zm7U+MZTjN`2k7I_iHAd*&c<4%zT1YWv_Ke%O!1xj5(xjO?q zW1Q$u4hN38S>pa=oSAl;ZAQE-0SBLC7@?!ld#C0)c`VY1Q|YV0Oo4MGr^djCqKW%8 z$Tf-rt)u{-eAAbSy4Jp|uF?|2dzAX2Reb1!A2>W|nTk2a2k&CWk;=iL4JhF9Yj_oi zNyYGT90U?7xRqG-m+NdNiiiS2J`Bv2pRai&s7k|y&|}jnzPuyrE=7$Xsm{x3n}!TT zGdDD`IIg;uJ-07=uCm^vs&*=^IK|dLrwY1sxI9C5=M|5|7w-_sVa>MQ?-Z9}8AL*4yFN2=*br&fdZ zcX5ocym&GuhJWr>`t(mtOY`w|;5scf`vkxJ?P)R9BBX54H+-$8g$_%cN#bMg@wpNV zuK5u~;wPgIsHA^05OG{8cEG#mVauo7mHqVozK*|jh6HHJK_0~MEfe*xZtaK6`uZ-^~P3aENDvRVq!$yrGEDcL{3xYc;tl@~t zv&5iE3Gf|Z#64f0BfSj=+Suang;C+zkK5QDAtsv%ne`U^UIredRH^Y;&>Ng>yHP}? zZ#V0$pYe<6)$NaV=a86ra|2lyq#Ol>bo83L@D<7w(XAsTSJ4fxOmBw;pUSYK>DPF< zOkQModN6U(W-ml1tmYEfCsFS*n^bH@sm9C0t@8*wU|`v)=>emALFyD@qQ7x-7jA~i>w-=VyB*tD{%)uBrS&-M>1c7mP7bZN zJeeB*#ZZFl;0g-nJfu_N0yl5L+tb&70HFi_U4l6YaQ8`|rYT3Cw}}7TMHI3E{kNq# z)DaS1)-@y9P%@_`%Qs9MdBo*qkb-ogI$&$6^)q7_iehp&{`Bn2HC6*-E7(yZ;x*Ym z3xK4Ob%O>feo00xQ{f8A3P*UN{%q(_P_0K4Pr`;@nbRdhNP5J#1#F}0JEQ=>QThx1 zbsK7PZe)6ZI5=7EG#$oBPX|Z!-xru_tB0%i(#CIZ`XzhHr+U+U(B%l6TU@fBp88f>@k*}a9Ylpa{&noeLPY3C^K-NoT_kSD z_~Xrwt7a6ZcUlbc-_D%q^;+XZRTaHnii1o1oFoL15{h_pZmN6#=z`I_j4La=@_$%p zd%r`SRDxb7;*(bB{b6uF3IW}>`u=~A7h6myuUT+EbTsP!jga<#d&o-mt}dod|0#{= z)w*)tW<&YD?GI99C39`ndmxFl;Y^NBC1;ksPTqNOYMTy}P!O3>2VRInJ^S~v4K5K? zLSVQw_Z}@pn%1Bc5~CY1Pf7? zmYBn{ocClcaYkHY++hf`h2K2Z_((e zp!NDy$8|{JuSf>;V#X zokuaD7865@j_}Ou6#6ICNOvwraksY~S+-J~qpX)f9o5Z~UA>&AOu$5xpecA8046u4 zX1)%UISd$qoLzMg1pIP?noA7^Jf&InK*y&cR^v}K$ zAPe#e8B`i`;JAc1dksv!_`3<70diS%!8KB1X{Rd3qwjZlHrhF z_RF`EK~wE2PzwZdzAZc~8-u542sqGIBw`wn!Gf8;+$E@_#KTGmP509cf!+^M5t_y- z|8&XEF3r8*W64_bvJD))Ph@@B$NO=PpGK;y#dekjkeLj1-58VMBQ$lVJUjEKHB6T#@ z{=^>m?+I;J@mYtj}L=y{%r`lCqCOA1sf)o_Sn_$kI&Pzey1!OB~U zE+!w#ff+wbnLP`^RTJubxIFSPk*NfLrc*#dLO>mlB~w67`_eP=0q9NA1Ffkqy@9^4 zAo49%6r&(VW=}|(0FJQ{WxURo5(&t-rw_%;&FPqT^Ar#tZ^u~Q!?zZ{?K)e)jiI8F z0SQt{9sQ-B`#*AXi3P{2v_s|W!`V(ih8)yDLH;}ISL4358$51zthe#nF!Q`9O$UCYzlytq zoKEo#$!Q*nzkuNlCxE5JyjfTzr~$$EpVo6l+SBMqZiuWA(1)Wm!s67&CW@@9P9*a2wdVT&3A08TB z9i!FEfB;LQ8ApX4$RVJ2{*C$oK2ex7^+AOUOj{O@F7RB)HnsORVG5^tUR(qJlj0~r z9$_C4M*0h(b*7re8IuX+pI}mvH=Xpj|9HdJiw)D4EmJRxIYYVq9K*Fc!=BuXHGq>; zuOCXZIoaF>*P0CKVBl9v8dGWjii&lYIH1um3kn3f95z64KxF#!5V6|-z34CD(ih7) z-__=K@1@@N%6sJ(s71w#>&loRHiL6#StcRQ_q-or>?8WKXUb+!a!3xu zGC>XRKbOaPeTrt-zv~y-pKs(UViHyvAESK!jt^bQ@(l5soN3SF!u`fX_q+n*i;W+M zH*s9bWwPT~>+!7+!>^EkXBqK&ri{rvOfM7B z-n8HA<(h`mjEpwAV`7WyoE~gtLf-1Q{wvbmR4{E#3qSB=ea_^~ARmyF-q@4^H$~C7 zAaz4uEXi%9uvR#uU!uYPsCy4(`<&$4+2&hmp^&O3S$~J5Av@nSqvV%TTRv-zLmgMx zQ#o0j+tgqrpC_$={<(o~p_1sBC7`*TC1g*5SwAnweD=-LNx33Sq9N^90Yh($sF>@^ z>*-jP9lm3hDSc%otnNjMl)6{-Rlb3gS+M`7m$y5k&-2xpj$<18NqUXq3S&m7e#-*6 zIyfP}LM6;I<*B0K%vlxDz$!$>y--l5P@5*V$6<&fX8F%AHAcm%S6aL#w4jIRRwIBw<{FK*_l zbPgmsSr*J7CY8D0COm!k>Pik9J?eT+DWh7ikAh#yS-1Khen8pcg}MS1_q#+~jK7X( zxlF>Zh&wSce-aB@rq+{0ZCkCkZw0s{i@>z-Y6_8VfV<hf@2KPV%9W z``dLR&T7sR#M&As6b<8PF)+tv?MDj@^4nY!Nb1U1u8 z=ixK3dnmNw^587$?UeCaT-I<=>5Ep3xpgsh&JWeKdxDNz`&hr(H4GbIoV^XcuL{P> zR^C7CJQbl!=>FMC&_;C8YRkd zP`qjl)EQPPimP*uYg@R6c-8`Y*XA4{P0Mb=(rV|MmXgh~Rm~TlwD=qjh$f%Xd~~o3 zy@f_U9OG|*@yS!HPW{D%y~}Dxf8zojLfp6COyXp3`*XMiyw2DT6~GFyvUM$_DY(|H znHo0#)Vw*W1$wk!TdU_PpzRG*a@kqVnVCAHrIjn}!(`jP17yt=s#L5X+^U3OCRoxv z6JxJC$g|MUx4ICZ+`*_QuEhyo&UBKvx>?W(acNb*sxGw-6CZ{{MUwwSzXtH-G zxigR*3#|CF6cL;mq_{?6ESWVB*`;fU#HP)m%B`p=BdM5X3P81q+A$=__eK}#0sK3QW+TH^`Ib3l z4}8u#hMyi&jm;*h0q?YsYSSVM5B^kWKm-f5pRpW^{ILilR6pqinbq0wn>^*g7?!-k z*gkSoYLTj@3kLA99V5G-beN2-*y(E8MY!0VTAFYf{nzUdx7mJK&}9qhcd! z#~l!yWXE@;rLW!DyuRPqj;->00vOnSY@enR@icLtVsEVW8xkLf`_O(l++jRC?$BO= z3a}zk?Sw?_V@C}W$sVqkNw?!@+>yv?o@;M`x0!R~&f!ni!WwShma(_?W{~w+?Wvts z5VguSn^?eEZU5A01(C(gb=Wu_w8sk_Oh?_W{HYiEFB3#a?RF=0XO;mm;W^D6^2#lr z30O4Z;x*^T=<@B&JAh<(I6tz20a)6reXWmb5e)5C4vZoGrbSMrCvpK|%Ytb2hNYDc zg`K&lDq}kc3>wEgnf`K(?7}Pfw)tv)czAdC%YKe(U(KiHbTZ^duyS&L0KHjN! zN_>p{?cX7d4RhCN3_c2&^U;n^s+M-ER@+HqZ7?);?@q=CMn2I_>4gN8BuknU6U6CrWDroz`(R?>^BV zjUoA`eM%8KD{8X7aa)!c3bbrl=HiQwE6tqvq=Ii^ay)^$n70{o&ZND9?8<}%GwWlb zS}_)HUCax0@z>P(z>aQbCcQc$O^ZBrae3-u^OOb0hQs5vYFaPC4u zsG#L>^2F@E!a+URDsB{bYQzJGokt6(N#TNi?W+HDXqBB$bz;9QHx^t}yDjV|G=cL} zzw%$g|E`7FI4RH2`awg-|6GLsHd771{nw3$ zh?1XUkXn^)11JFn6|4*`%@nDGj^yBlrmXbLtf0~3`eqPt)8lryiC%wvMJp*q3v@kA zy9RvF3EYT?Xm*B=1g(!{9npXT)- z5ISrRsg8}kjmHc;rFmPzj(aJ|`fpTm@7>|pSWlF$rFfwurCUzoNI-=2VZ_8!a_TOA zf+iX`uD>19+o23O59uaGIB&pjLv5`M$9Jx@M3PU@^PNUl8DG1=NbzAP*<%!tzrmh! zJBV19;^{-8n?vUSgN28R5_3L^?UAkDFM(!_y=Bz{R;u_SE5fTO&Rue>?h6E*Ro)I2 ze1Dyd>_3F+sW$D>&vV1{lPlBwR;a;KjwtnW#B&tgC9i*+TS$%u(%V{{r{nF1@vFsc z@Ga5{oXiJH0AhtEM5K((;@}rxeiJg&=NU>JMN7ItLz8Pa!~gWl#$Qa@zty+$^Z)G2 ze)D+jZg2ZPZOroRMcr{*pvlzP)HaKARnv2FaS?lWI(@$1a?=XpL3fkdJk!zjT6-mj z@&?i|!4!Fq-OO1vvERrMp(asWGp75pD5ahvwzsLlFAIJtltdEezTNVATZsG;$o~PG zJS3@my|&;7*rkM^7#@W;)#&+GR@PS}$w-8br3-TIx5d_4wO;&~$yKL)BSMK!wud{I zPZA77DB^)y?hd&${DW!!7S969-#M*%%zD1fyRqvF zG8vcPQs$@u#D%ZON~u1j8=6a9EajZIPdbvPJR6^1wma;l<_ai~>2A)cEt;QE>yShB z0gk-~Y3J4og+%F26@c-TC29di9;N13K^==|gK153IWAKo)bF(lBifn(cxV$eiEX^@ zq|0aqMqWe2gh|@4n({5LX1R&DvYzoK9qNccyKhQc0e$q@g6pyU*tl%_G+B(6Z&|8> zt*SMpHJNmB)20erTG55=<);Z-R-zAOaOARjbhAoh=`+fi3w>0hFL0F$ML{s?AvD8p zHv}h?5)#YfC2ZHyu;mXo!1%aNPC!K%4<&FEsTT;p#Ym4a8%?%Nnhr&tF%M4YjtnJ) ziVUa;p;ww5zTBwo;`Js&!?!TCbb<@tu|({25B`d|6;Imsf@8D!;x!Z;Xp&8U&p>q) z&^6Q6RhO%&iV6stEJd6Dp!r-B^R88pV;tumqB z_L^cf;&F~h@WY7@^4+`{ZyPQVQy_A3sDCK1NZ94|(B-8DGZy!A8G6Xa!|Q>G(_O z%DWo1t2EleXpz4x&|r==;5)0HL7g2Y0D@sA)@6cP6H_-gXYc1KSI_T@rO#RX)UPij zC08E~&=33ZT4o`Lt4c086+Mi6@HDa{!Qk}DE(IFE+pi1_vsTWTDG*K!o$+jO`h)Ps z`s$_?+ewv0_>!O4VBXnL0y0*wvGhfVNZa~3D#T;%ROPS?(J^RFt(iK&S1*Cz!)fH) zrblB7sG&?){bf&z^qRSS)O||l2`!~f01b+lC3kAZ3C~-xY6DL@6}F;o%R>`hs#n=+ zyvItrqMNMt_x?QeIOJXkI|hiM@Axk zsU0h{b{dW;IUBONEp3Ojq{Ui8`i)t^ivWfd#0vB$r4on#+X})}`A}JEO%XeMf=K5OSwdT7c*`ogi0-3)BZO*tQQQoo z+G`$b&P0V=p|shs!Nu9PDl&D-FReT-+W`T9e1{`yo_+_dg~^owP10Ib|1$aG{0&@U zcZjP?qH!d8==DrsrRd8Q)CuXQvf*)QPK=e;{!wYQ=8qdH&@Db3eRz@KBfqDs|0Sg$ zk`R9vzAl`!?bvYM@PGxwN0rS!u3SYpB#2;C#?d~Jyg*Mh$kmSrUq62Q>Ex4&(?0Fy zOQMd&wMc+z;kNr6gYX{CyP+F!AH z7FLlI9!Ya2LT|dh5zln$UB&BNn{&9Q=Z*+Ie1R&TSkCYV<@-+? zu#Y$LGjG|DfgZG!Fl| zYeG%veI1p3&%KuKl)np%f)a+`;rLEDXz^hxg&`F3nOkw63d?1zYLk!=({w)5!B|#4 zeKg&5^ht^xZl6+7cTHz-!M!cnTOaRXSXmgi7C=Y23m#aaqi}=mjlo%kQu0YJK56DX z!EE0_O6%oh5!P%>>u)&i*(TiPJ8DWUCLfhPYq5zbZOb|zVJcribZT0tK}%d<+XX_yp97POAZ4!br#Rw0fF)ZWL?c+5 ziPIbs2P_rlB$j8@c+H1rL2)Qc=j1U7Cth*C7^1$x|9b*@*>jK7@*}kVFR|zUoPes? zyO{n*foHAikHC`+>0h0`BVCLVH484y%VZ`hSQ{7a#5J+n7GfX~Be5-VqGVuE4w$t6 zw4coPhD4-SBS}3L<8Xt?qsQxx^E|$P|5Vb9qZmm9c%iXG%GBe7MT;>t6+u|zMtoak z0-ST|f=txnVe)UirZ2rgtqaH#fzGYPTt6y~Kpr_IG5^onUosV&z6Ed2Za>}vp`LD?tXU6?_q0-LRv$8tHQ^3LUC^Gm7>klUY{X zZF=tarwRnSb_KlZ&3;;ivoe@Dh^@@Xl@@kpq`L<61^DKOJJ~YZmf~!w#=B!u3}eUF z|JBqDUu%Z5CK#zo$!ffUM`|JQ_P~qtDnlc(=i4$%jiYtm&hnD(>PlnAlw_PkeYfwi zu)h`{(vV>lNC-xP@7Zt6L#;i~N{&On1yflae2>7)t2RS}sWUyvfc@N+QYjKeaqtJ) z%&2}g_$85aEl<{&&J&n(m($CTM@TWiI*fgtc@BY>}2CzCqYu!vrU zw(`~r#qjwqR~2T_W~ySyX(U6RnCGY#c!Alv!$XV45@NPQZzyC&l*qYx4Dh|$aG&Shi^nP5I*8}-u&0Zr0f(l=0gu;LGbXrq z5O+rH06Z_V?WIK!pV);!Mdm8tMxl<5E9!q)`4pS4v#{7ADvuph)m~&=XKlgLx(9Q) z=Qw??Kitx%>yR#(Rgju(f|FMm#tC1*m{eX3<@&40lCu-2{Ynp1n*Vy`WGhdXjBI&vrT|$i{*B$+vnO6&gYiRKo z-5P(?l9L-Whun9oW@AEa-_+d7$ z^V%8YGE6U8j^1b=M*WgKeW&xR`^wo{yiKqn?z-x^4{PldqRZ=f+FvSK{?GZU6XeuY zUMM{FX3?EVY_>wsns;bx<`$WAU;LWTZPnc7E2{*;Lf>evuebnD9vxfv(t)zu2Pdwp z!8B+q)CiLTl9c~bZlC*gr(5ZfW&9^+9_%m>x8txA9~CDF6y}!L_4olzb;YDVMFSXL zEMyNaw-(XEX+8f+uJBG<3qhEZ3D~OOZuR!es7hoI6Z28NsezSq2X>-;+D&V(QqZec zygHU%0hv&36^AKj^v{w8CvUaX+f9Bq{fGqv`Xsv*42@iIbl&0{%@+W=f^s!Ix;Y$z zQ%_b9QkTq&Y)Y`3Cg`wGNJu;wX54C`=T17=3pq_WM>S8I z80uUm%WGnQ^xiLh-2mq*(m7-(!ugvzEkvciS`(E_#SD+sur8Kq@AegHoaoq*^fQs7 z7$W{oUOfRGMuD_5M#f{>tDA6x_~tjAspQ)@^GCb`?MaNoVDotr#ybtMfSH;Bb}Q0C zG_Q(LuiUFiTP!{6KQ!54!aqmY85*jyNAI{@nCN<)gN6ADA~h|+>}X1@Q^ec{@fiz? z6&5ZsXH1ySISNe=gbX#)i0U0naw&5r*`@O5e=2SM>|{L34^5qBgtqn4-94G$ki|v9 z(ywVSE>Z*JuInMK22vwB;i(C8j=~%%a}Z=x8=i9tKv(&^mK)wJ*OKy?g1y(U7tu(3 zVW565mk>uf!Jx}9Gw^|I2~RgGGiI7gvG*AUIIgiYENO?lZjpqz?AaEiqQWO1-F%lP z+J0%HWU8LAKT{$dWk-wb5}~=g)TSoBQw>KAfIs~uq4AZ*u%x1)i}0P4W7_VeUtZV9 zX@yEz)m(b_!d((Sx})BW1+zL<(S_U#v*Y5ZSPO{4G!D=g6{|7?wTO1tOW{zIG{j-$ z{(EM*ip>l@0VI8aFdLZ!WTbnEv^I+UCvsDn4x6IqB&GD)hVF^Z(82NVkd5V8W>;}c zvMT24{PghTF0_^l1*Kyf>M^t2@4iyytj<;{L)}sG3VWiDGY$AHF#-V~g;^m#0=n}x zSt@Bud_G5Mqc%j^)I9BZCS=^nIGa>ZC}q@!1uHD>MjVxaTxHd+S+?jo@CcHn4)39E z8(K#$& zH)fyE^RZmEwT=1E)L226gz1DByrkZcqz@-EG}FZq37h?Nr~bduo=)ddv(7x0ymf3? zP+=Z*&O(%|Z~VAxoaX{AU$c-$={Ba?&$D^?_j0|0@^BOiM0az<1+(kSkvi(SD1#9U zi)bL(VACAA8ZIdh1Rh+x2$Iouh#x62apk7l$F*Z18MV8T&^K~a>tX^Q{(52;mmMKB zU-t(oAqK=%J1HR@B@u@|SYj`6RDq&$-aheFkGq1vz^>A{FtIthSig}$eWiY+kZ%wV z*1-u124CCcsyDrK2DM=Q%|HSJBxKA@BkT|DU)qyR0eTl=+TrAe>7Sp~Mktgz`WyGJ zi!P-LbHz8w`rDi5&u26%uZvFj$3bJOR(+Dsc+cAz&-|^>^aHaZPd4mlO_n@GBSiKK zKIz;DPpH%(^#*$(xGm+A#|wQgpSxcTxyt94mEKgZHMgrnbiRGvmb|i8&m=fLrcm~T zizBHx4|Dj~Wq*b8BE}N?)mjho*|GA9ACqr$Xs~*bfwctmbaiuzN8A=6k(E+M$A**6 z{xaTsIgt-SO|bxl8xk?ZcIA+YUy30gwJ>a1pUq)i;&b+^EdCCTlEl_zw*pmF*q)Xe zOx&8s%Iy(03%z!5hR;o4;@a^nkWI9b&Yp&0(VB${n%$4UgV zOI7lkbnyU7SE{GgA04_vQJK>x3(k;(MwjL^J?ojp=}jQ0UNn)xSWD6tJj< z1m?7}O#-Yrg-vO5PbMpz^@G{@Ae<2NenvBHC(WD7kpQxCE2 z$+aY^Nk<676sREVM`0|48MrGDiJ!n|=>s-f$47&wjpG~@sIt6xemBpHnSRQ>!yY2& zEzd&3A>Euq;Tz`$SNN**UDWYMhu%f1c@S7RFB%Rb= zpq%o(;;yJ~?LURnnm&^sw40JjcImx9^&rx__pwVYH()upcD1#)iajrk{W^nNk0IQg z6+IUb()tBQgkenRwYdV#sPcBz&&ks$cPJ3KsFl7+n-3`d6IEnll?^w{V$PjryH@;G zG`23hLI1Xd&)-jPjXhgAIy_Sse>Z%pRZlXL#f{l{ktal8U^w*dG9+Yr{sIYT zl%|iUzTDsvuWg39+RZMN=Dpd+^7Z3)n#kixUKeeJfCNL_`OMrz@tf0wV|k6DjOW#C z;K`CkvG>MEwazg$^>O6;%jel)0sgrUl^17NK06bLk#(FuE!o9(uIj^c;+fZp*UA5Y zozu*oB8tNR0g;par!DEXsk6&}jVmuj8h?3er;~BumqTdfq%eTmMu9opJ zD=dKDxOA|jPB%!6L=9ZChNB#dpJmf0wsv^O_O483m9diX7ael8mnGzhu`n0V{;;m7 zVnzvFl9CnC6KMY#LJA_{yM`v08D5%qHZ&akX0S)Gx@R@iJ1?#TKMH%FB5-r<Hv6 zk0wiQ74rASdUS(t=bDy*`^>=lroOWbT)p#wxm7p1L&YCujS$(SoCD2>HqzJEg>wP8 z)u2;>QqJ>(Ch;n|w~huT|A;btJ6zxqU99qIMoEkxEkX3u)>*Uf!6lg$`%MH>8lq#W z#SFQF2H!nCo!8$Nr2?4TwbRq@X(Je)Gl!a9?PS@t1G+`9H_M3$bFZy7tLn7*AN_J& zT$Y=zm$To4JCOe6zU$9+@dE!mUZ}#&^R7>!eY-l2?L@2|lAWw^X%*Qt$BV6SbuTXE z!qm?QoL9!xSpD2KUj3HELho6lc%#+&Z}8+7Yz#TPOt4t|PKtU3gvuU0>)1?)J`p7r z(c5sRGULIzwreu1XMDMqfx%qS25ntrHqhM_=%-oHrC^JbNKnl9UlTazw zeNJ9Jbgls5Qto+oV!JiCz?0SJfTt>ISkd(6p15}qLCIhBt?@4Lh4D?qnmmK&`xPal zvJs!r4kP9f)yAx2EBZk=*A*9Z_0MrVH%*e6CMx~u@mYt)duh(OgU_Ue&xhPmv$&s- zago%9+CB4!pdnT`OtEH89i;v1GMkk74G~$+j}`LAhm%r3yQX&mt)21~suqc|x9xE?OkqS0?Fa zf8B%#D_4-l{w7;m7l?x)Rmw$`3~XYbaLExA#?N)f+3#IPA%?1=AVXI4>l04mJVHx2 zG@|g4w3ZFt&`$r=r&a7QU^(wpFI?IMb6n|>LZtpyYWpJAbFf|-O>h!6wnGDqMw=fJaIrDvGLg~gvcPoC zzC6S6d9m_&agYZ&&=3`sHL)^%Hj_%&g~XE-P0o_piUL0jy9YF%@Vf+As9(XKJ@doa zh{kMc-u2yd)C}rS5TC+mzM%V{Zyi>iUr{7et2JvF$0NT0k)@jroyh>)MvBhntMzQx zj*BAFRJ1-S_`NEJ5aM%hO8X@3ivU#7kQ9h^+&dt|36{U|VJtDfet(X%n%2;>Y1yRH zKLE!`C7V=e4I!Vi_s*Lua3Fl(juP_k!Mq@4CsiVfxUeNE8>+CYiB#hm!>rA?SxyQL z#BSjX&fxq@thm0oGNCJ&5#Jb`RmX4581}VHt+_@mH(4J?t(|};eW2#HfD0%dww7Nd z9$Z*{%q9maE@5&cqsbRY98#Mpb71=Uor&hG^p60}fruY{tv_Hege6{%03A5Ad+`&X zRru1HeF zYttSg05cjx`xwdK|IfQqDPLwUwK0UR@VJV^GG`LVqdLMO@Gbfl_XjmI4$ zSEZv3VQfz(MDek7b7;F5?QzP1IwEbd3~DBfZEmklAC+hMWdu7rfzUQaBDFYHzJjwc zT5Cc`HuLo&&X37WXzxn71yKYH=U>fe=E-4cQ7;Uk`{aUEz|@6_knvWB%knP$AcPxf z^Y+&B^C9C;mX1rghf)8PHCh#i;jh(iLAa&)>|4Cx!i>OlZ2Xe^<48|rPE%g`;B*mL z)W(S7P+?0m54}=@OoMUMNfvoAL^>|3&f*zS?5EY_5kv`ChK_$KW=c&66JAu+;!!%L zbAK!9uxVwev_T~riI%w9+Pz(#JW;BgtyL@m`duhHz_FfN*QZ-_e*}x(T%TzaS4L-N zO;WT9Wwjk8gU~`$E2hPWaIhfO%gW{*bnk_wOt}S6%my?!^9z+k)|@7bL}JVW(@n4U z&CsmL9~hK>ov>C3UW1u83IC%r9o|+UaisbsCWUQ%^otWCD{~+k$;zy{PB@*BP`=uV z7F#oFT>X|kM4TQ7#PVGacEmOsqcM@T(o1_3lX27?n|?mU)MAq)VJP;po4G58*64=C zKDapS#0YUo10%mhF0v3Pr;N9S3ccNft8*1Zxm931Fh8{>UMB^p9wrp{T!K~_&Bf3_ zgUwxrsitWQoedOOx)`Z`Ym#tCFtVq#>7oR|QMTBQ60#8zb8#0=x@=iEA&Bz)UlvI8 z(Ee2zK&_W+)&Z$7td1~e|NmtA%YMV6%(-F3~};2&FjpkZ6>Xq@7MpzLy%vsK)APV zDaJqzkL+nifM@utIX94AHG^RQ$>m8V!GvrZWJD*_c@XfbNPj8*ITHJ4eAg*$2Jej@ zG;BQ+jByzr!dyL#M24jG*!I?LdXN&KIbms-PUuSt)QF`Anb9wVYGiNyUAjFsS;Sk_ znuzKnnbu1BtSCKo#8h-mPaD(QpLYAw)560NX7{+`Orn+2R?B*7WcW_Fms2 z7zm?|!4b(xkpUDfe7}^r_Oj#{uFK}?aep3J$C3l$^Zsiro_4JAq7tT)xU}G+&H_7W zXZxY{9c-pcji_SDB64@NWpU1EXK_Wkeux6z2f{=nn{9fP9VrY zH>$qxiOW|`#7*OR#5YwkT|gSsXb;}$qVDjEK3+4J!U6bwb8v__{6e=gf+(~8U2^Dd zX%`*aFv1Bi%v!USv0N6!oe!To)hf1mTF}>He6}uptu(OdPSa?;lrJBD&ly5^wOGo> zzFwhl4LuY-&-9FZ=#y?zwq8P3OW6n^3hoe*4gyk%QvRWFh%ZyEX;`~0l9yJSoB^(m zvi2`x=|6X-z`%avh5ll0>&=}JN*gZ%Ah)3-%@ChSSr-j^1b-!{dduT%ZD=EpzW#tXC*DilygqQ5-Zj!?>gozbnjGH`sV0g!8{A^ z=+>cre5EmnAAGf_DHfrmTWYfNgS_H()w=I7y;g&*_!}o;qmr%8AjR_tbv6&=WJkew zav<_zcIK63!7TmQ;q7z{So^BEMLZoXNT;^qElsev1!{?B#E{^7tX^ygb6|6l5(R5~ zbFwF_xN&=?oH0}NO%uzM|1P`S=N^Z<@JMi?x-udFzlnd9D>vl-WhDzRPloAk(VQMo z3wmW?n0Q6yz*fs7dFeqHJas#oC}!pp+FA@6Fkj6P}F zbE`Im!t%fLFl?(gcWiU-q-36vmk6!Xb*g9cHu1H==G#Nl3I?;rE?mR?%#3>YdP-$f zJ$~8il61gddK~9C=(-YXMOjY{oY$~QF~Fm*MYIS#626U4G9NGY54m$+0|V80BRJ|+ zbB+}d5%POU@BBVG2pi%@C1;9aOp*N!6k^{d>gHC1cJ70H;<4fSC~}Csc+Cp8-iTk2 zzBuBtKh+@j-MXUFeBPxKXha18eH*i2^+YQS4|g*k z;_Lk$+io2%liAYdoE2x_c))MdxveI=VDNRYA;4$*5al}l^Ge6UR$TaTB)_Q&?#-#s zSI6H?rB$;d3*yfFKQtB5n|FGQ>1!6i;Uz1rYE>XAiaAisFwzCi{+Ad0#Mmf+wMEb& zl`$7(3}M#m45Q8}R%ToibdzYH5GceEaNf04IZUWo_!G1yGx*twwNolxRV@+RDmB$= z=u({O;RZEB>X%F@peOk2m6H$#-*WCXc8ldc07^;gFx29JcpoXV9 znrPLmv!P!=&KJEn-eP!iCra+Of2V->W^C8jE*3DRB8i|6-T@M3Fvur=hm7McHso0h z0|?XmfTP<-J|MCD=%928zJj-pNv+)6>^CFWHt#OfTTnpYQWS-Xtw#l$1pO{D!&Mr6(Gq0uf167P1Uq589u=vk#&vte3J@u4 zFYo}2)wqd<4Qq#k_EN`__uf%%2FA27WC@ze(rAPwBnaA$u}0(4wB*5~t5xQ`;p)@Pa_@Pf!cSdAC&hV5qk&RkXN)f~F#0q{OYTNRtr2`o^e*`~=3 z$}+P(U854gSQ>WlHQM4*kPmg-prAy534N6++x>8~w%){4QE4%ENWQ_|77{vi1|{@G z6j$`xfv8JZGe#c_+-J20i9qw54$t5Y1-OEqgE!T5FDFHpKFi%dgAg(q7IFz#JWOOD z;VhdOA>M4#y`|&7aUJ@QDXhA4hqdIg5!6sU-}&HHjJ8T0m0J%cG*gyHmMc^E;h%42 z%QeW{akavtOOHX*62Mn$y&f#q0K9Lb!c8Mbe1CC4w&h|_9b2BLMI!i@I}}06$Kem0 zDNN9hykh*nMfZsJOxw$!Qdhj6(L(nB>H7R%qea%x<0tU;-=wxF4ecN49o6@_)}Sdr z5m?49kRP@@2WX?+5VKBZ!xl48IG=3Yh(rlnii<7stETHF?Fv`Vxgp1B2;Yn_Rutct zcjYEtZ;M@wC5tYNw9?5{4!Ro0rAz0!m400=acRj>dpYrU0nGBKXIqc(UadvtnDsCl zN+LQM3d}#aGSe>OVl=MTR5Y@r@kxpX&FT6?X>=+&kJ8^h7U^30)B2NU8)_z*D}j>a zj^r`Hs|heuE@S9}J!VZnq+NtM7LEn1OVz~Ij*pjXADNfdxikEezq}RSRhX9+g#oB^ zk=d*^p&+z0R`m)po`1FRUTt~1F+67;qt-?OT(=@e9zjhn!+)O;Z(Ivbt@P;Y9~6o8 zSfC{_LgUjad&h1zV|Y{|Fz$gZTNAI>leRqVuz9SEi>&+S83Z|NPf&vVnXG=%9lgJ; zhJ0%}uxJ}dlc_Q)oJYISh@WtpRYiS{c{p22VrWyLvXp^Dd zQP55Z1*9=ak*|swD8Fa>YryWo(;pC!f&;xhl9FS&c(8{$xCM*ePHo7m!}oezR=e|Y zcP|r2!a>8Lrwq`BY=w%Nl*UaTu*UQUD|(f)P`m{Vs*JRht`8JmK69BpxBVddm8m2| z(+vNV&Bxlb2<$MY1&P4BGQ@4}l?zzyGzfKo%fPLSPPOsD%3dD^k#06sX66Dyb=i!O zN6(CI13axhFb&7|d=tCY!JwCp>q=&k5BU&Qn0?LzM+?f(Ul%x!yW~FOSF5^nEUYe^Fju@OW$d2-%ay|?Co0_`ZmbOZ0eQve zK7^oZ+2;f37&Kvrqp#{w#sbtzIOrP3VrDoBuwpEh>y0!Aomg_AK zQ+w`=V9Mo3>;6lL0czsu;UTG5F;J3}ZaCWOhL7mZ+bp8^8W=qQ-n%aIY2a&P^&)7~^y_&z1_S8EkS;yFFHU10`#e%X`~~^VJQ!2(7{7#%#i2 zVXjy>3tHe)!uHf5ZV0mw54Dv%bsl+^6)h^}G@lTM0r873>MRtjeyOTRVvFg(T?;OP zdN8Dqg*%UVlhi0*rP!oCHw-;yuZcY9cT#wW8?`DdPdq3ajK!A}>7yEU{fsfIMDxk9pYbRiLPfv}c`2B3lX3 zB~{OpFaObnQ-EM88LslA;&nO&T+EZor9zCWIc}c)Npol@Be@}ub7b(_oLOJvjdZSs-7FVMq&w3#$v`6`sQS(o?3mE>ay`A6|tTH~oR!5OQe$N;rT!B8jKzO*m>>BI0VvZ?(w3j108`h zB3=#v7tZcBA-^(HH`@abKl3b|J=r~3277q7WO4{5Mz(ADdtL%2Wjp%*n$GCP3)P}P zo^~kQm2GatyeUAoBL((fO;8cSgH=J$JUoLx)C_q%H^YTuwdwBeSBVCNFb z?&=5nSZ`64M(AVeTq#@uGQq<8l=K~oH~v232ia4b-P&(mvK_(VE~U@x5UR=QI9OOV zs5uOysr?bteHu{ef?a&gQ~TJ441J`R?vdZF88^j{6#nNdNOFRrzCYY5d?pF1W^pH~B`tvJofeLi zb(xhFG&u4Uk*lCTNH3P0t8Pl|7{ei7ULwO-+XEh4JjsON8VT~2&)dVtH0)CIg2=M0EtmbJ9+j)K)Q72z! zkJrO7M{WA`PU<2vC0o(?NQEoT>Wc>=?!T4qW0t`p<5?7be#m_%`LZj-+;PJn&d4!< z71hZg08@7?Jwp*E#42Y#`c*UiQES=B$t<$&cwU(j&v8PSlORsmhZRwy)Vfs?L!y|L zxd>(Tx(hJnkh$g7ag?xV-C`*h!Cq^);Nadpq#Kvh$x z{y*>#SU$nZ6X4{HPCB4{P3&qbz#&`IT{Hdg9g+(Vv{Jx{a`ZoTJq zK|Pd#i4!DS7O1a5ik^zS5}`(|jF?A7pikPx=>Q#-EL5>LUnI z$mkK)b`^wW1ObtjY2X2|0pO&m;3nLQ?7NrfJqb$PX^APYlg@&jb$5g=jhlEXy2onw4#cJylr&gpB%%%^q}nb1SXg zM8ymPANQ3%!psbrB?j{Z%cpq!IDIh^HGBTHN$rez8j{{VTS%?58 zG5G*%Hdts;M?*w+)-w5r`1J-h&swWLXcdJNePZE@gmc?RWsk9UfomHH*ece*6x?2| zyBmwi9Q8~GRr;TQXQ^#)E>!byl0T6BV*?nki1OCVFS<6HAgso`xO`B`E!}gyfFy0x z0F~mW-tkzz{D>wH=XA7v@nlU4LkxI+%_Z?$0eS^wn-ud5N}D;~XixV$6QE6)Ym>5pyl=9r*EPOPR(PwTMJU=qM zCLg_lXCr~h6513ll)d(GH4H)?rhRc2d!b%tkt7Nkfjmc#&}e#deB0H0;v?>NRA z^?-1g7i@GhoLd-FJ$!zf3IJyEX{*0&~)Eu&;@aQ3HbfV)QsRo!qQ32~?K+~+;dbt5 z4j4f);rs%@k*ks9HkDBh+WcAkxzRnM~lLRdInhXkt8oK~d} zpbdq3Y?-mwN~B%2CX=+1lqA2$wf5%UY@F`pE;{a z8-*p273*1xmS|tZ=mgfVds9#x!PQ}9`CLO;?b9Vbgjh|B+LP)VV~gQ8cK?@!HYUsb zL%k=V*dxf^J8Y+Oe9fFzVvqB#_88VQ36=7+iL6>%#-~TCvc?8pI>Hj(xGtZ?f0h^Ecc(#Q=|4e$168OZ?!h?L|ieV`B<%%f!Ik=ttZBr6^#da?s@?p zq?Trre4P$Fq2y2atZWnu2mNn7TOXQ5!Fpxe`%`ZJFsOFiMoxc1f|oR~1*g-qVE;*w z*Zb#&x&XW_UCTtyRdA!FOip~WaIv{HBnH$#W(C-2oJKi*QR@5DuH#OZ@8j2%!XMu^ zUyCWVvc3=;?`NGZI%1{3d^{&6*(7icKMvK;T}`Q2BXyrdUX~~Vm7Jnp(aH)dQHf^{ zCUq`V`pmoFqH0Gw2`V7h)9O1UVr5OraZ!I8RXAp{CDpY)i;52Hi%!qom(BXf29=&M zb<2mKiR)9Kq7Q@p%rbV30gy5Rd|MPlRq3Npw|uO?$V#7%-6f?l*kjY zVCRD}l*;?}H)5O@hGO-5-)p7QE0&)ItCQ&rdSII1_P4w`tqw|s9o77? zy#^0|S13$zwSkTP`k3nq-ruQn%}AKI@-0`?eK*FYFS`ir8Ld%D$PY8+`%E^bU*}q~ z$n$1O)pJ%n9Ev=%U(F#0`JRQgoT)z5oCyKND$BXUX)e7h`?DF5%r1v2M!E9CRP~*} zGM9TJRte~->A#IBH^MCfr+&A{B|7#yz&3w*>ZXMKik;j*b;+LI;W|c53gvt-$0B%Z zMf1mmP~691n(2$l5U1EVXM}nDls7m7(ym|DZ~-D^dVMijbhB}3v^)!hnOhK|cMa)f zX3*-(E`_uJh!;1i73C=u{n3{#H4`Md7DYNx$3z^Ke65ndSh{Nh4~ctaO!oU22(owV zGLA&?f^H*tLKVfVoEXvYal!aTQu`b+_F9rtZ2_ETq(R{=0N8@L6)j*hP_~jRgA;$3 zH31(OxFtK2{ezP$g+C4l1hz}3zeBxy%>!*$g_=F9^#u@i9Z2*8G9EhzrrL0h4sewaSp4X zgx`?BnKCOelesUiHT4EZJmqTG)XlCDvCi`Y=wK+jwbV7WCJlJFpFnu;KN1?%EnQ2? zn{5L^z^_?jBrk@>^UApnPqsWP3KHMcd!1LLX^2lQPTojEv&gb4hkc-28i>tnwat+` zj!4bWqCipskbvaLWRD@$ZcAEClg<4atv2DN9@!gGed%Ko61d#Jsby{{2l70d*2q8i z%9>`;j+Z>^oW1ycPk|37v#f^lFi>ibnkr-#3Dqes9IUe@C%;Nnot8LGCg43UxW*yt zjz?fkk#$99^n)4a8g>z5_wGrc=LG!Z-yeFuU-j<@D_XySipmB@^WnC7#p;L=6M#Bw z3?QSIZX|e6w@Lp-qT0gZ0xjp-!mZoS&4N#jdMye;Pi1Z^6<%UJI{L%onslhha7sij zo_(S}hb%f-7!RQS!c83(yeY?gj`)&oL|vcT*~kz+cKmiX0k-esC=eb2tr6+Q{8R%*+>fIZzpb#HR$PD z!zle}Qiy~dTV4}p@4xKQTaG2-a-Amwl@_E+52^Nl@mNdvvGmhw`Z!`O6}=0)u7xs1 zO?VIW#NGZ2ik<4jX!#_D%^^h*Wy2;qen0=LB|3cL|F-MO{(Yq#p0p6?QTV;>Eh+5u z$osm#uejat2v0_mY_#?h>2;Xazo339_7p-3ggMLmj)17F*tV2CGTQuumErx!M4V2A zT`?Q)$As?HU?TYeQQ52MjPgg8!ub$2geSf$eqSw=^<>P~^&5#YU zb>QhA)9(Om?_EaQnj(`35)9){#*zg8an@t_JIG2EY#X=Tq2FX>nW|zhY$ngWX|d7r zwgJ7APB(0`oExmWsiE1I)#rF#6RT1nh=3U(oP%_7l(pVJ?aa;$7{SJuyb{mP5z{km z$`T_^53A3XGZSoS6S4mV)v4t_l$MS0yL?dG9&8+Fd=QFTJNr%$1h`$Y&XU-W^rWn_ zcZWg_<<-oA5OpQqt};~{t&5k4TB{cIK;Rd882qiay-h7Y7AF+aD{Y15aQMWy-#k{HqT(-dXMx3LF98hUOZ{%oz*6aSLhT->j@cz&$+6|8e1G%_jFO61&-JkIwQ(aF@0p(zUl<4)4` z`$PTNr&vx>Hb{|z7L~gs+UN7v8SDcIxj@7rE)t}ln;q6JM!8n95wn_p+~iE3ysL;{ z%@Wqzop-5^^D>a>`F1XipsX;y<|lQKEnb>0KXa(&wrd=DbOW3_ae^zTGApFeI=a7p z1d%#j*(k*r1}FYo+q4{D$#ba``OaY~rSzbic{&@qS~P{1O99q!-kl82|Mpx}japW| z&b^B_zu`CCz}%zR7icKItzCTOKQ0-l{c#ISF7%2a@$+M?wU|dqxlq;<9 z0~>30FqqL)c;66Dk_g~X1!~WPMV7O=v6#?^_Dr!BWqfTKo zQrr}UV)9p^ltF--YgNECvckrNOM`t9{XFLN;Ss-I^FuxhzCU=vv1S{XP4TB;P|g{8iS`R zA&*S@@oGbH3_wcy?yo0z^gY6~-}mY34FQV)EM6wTf>2x*fyAZe#LDmLyNorv5V7_( zRPjYvlg>-iuj0Gk%)|9N7j%^I(Cq4gy2{z|t~bHlj7P zMI#WkPAYDBQyKa*%+sU_ZZhW+j$CKl4#HD0B{m+Q14kB&-1dch11)v5WYxcYy4Rz= zZbJDPp5ETl-$S2J_Iv~7N8)&m=k4Ke8av1Ut9g@P%EbxbR_PnCCC;jmYs8CbM$DZ8 zb|2Gu`FNBwR{T?F>12^7X9I>z6TK!Hj!9v`z9%7_S72We<57)fXPuRatS zGe^@jg;O!@$}9h?C%3(FN9rhkbVCpz9#B+XRhalxgmc%5JSKy}0@bh`+^ATIIf3+$ z;it-zl`UDup##yZV=-e2)_4WR!VcY{c7XOvBm|8@F#R8j0nJnN>R4h&d~SXvx@8as zflH_7{DriMj-xI!iMVzsf5BN>-j7Gk{ z9{e4VjRzztbxzDLeZsa%s*4sC^{Wgs+tR6~A8U6d@i! zaO!>R%j^}>Px8}d9DM}o4%WtA&rXoFD3wnC)dyO@(rgrCwvtDy5PxW)o zbdC#IvDo>=a*R2Z=_ow?I!*2fwj(8Fh}DnA>mn36u(%|BWgcr54pedx@tQu*QlQRQ zvn?arjuh@uO_Qc_bouN@noFPN8lGYIj)0;IN(2A1alUS)O(dc>;n z`WkmH*nPE*-W|wcXZ7- zQT4u7t3cx4US*km=oHCqASkWi8?(VG98^x9&ZT1K`i2MR4|Dmt&rl z@04uq&8M;(Yi$yi^Kh4eqPzCn_6506CP#;1md94?pqtf!F%HuCFJHC30Ipusr6+X! zQ&ip7x9vbnbmX2`0 z-1DMv^-6XEI)B++BaA_IK^(C?A*Q`UQo!sE>H!kCo=8Y`qu^XS5d&AG%n-=u2S;R1w- z8G2MwGQH}(^Or9e`qdSA@t`2=Oukj%z!UH7tp;w{R5cvQi=(qo*9^{C$WP^ZN)bd- zJm%dh8mU1Lp{O8JBbV5*d|U;nd(3YxXXAMh0dF)S`xBHwL_hlbPpYY+w;k z@6{1qa zCKqlR;ymf~)xuOjOYg;BC&=n=6fFiaEF`wy>(&0XjJxczVe81?@rgZ;>AhN$9jiuU zd86qDd8)jSs}a7kRV{`Wsp$^-8L9eg&s}?TE1ub|Cg)Zgra#H8=!Aq>9;W$>&;j%U z9=8`!-T7`HC1%h_l?XRu*VDj}v$64Xun=#kL|i z?M_7YkqVtbR;u1Z2;d`UuFm^b%8WA>7S*Vu!Ni&32)K56DMu#{tw#F(AJ`$B>X%Z; zAJ3J<4+s3e+~)uP3R{$@YujD0BYr+pW3v49WBjbrcAxx!n40SS_jt{F$Bc>rw8VVdF4j>zAq+fIdtp3hP>F}Wa zCpI2jjI@GymkZfpK=!}Z68OciA_7HgizAh4oaXO@)u=! zaGeSv=!4bvTr!v&b~3!vAd+m^;FQZfN;dvQul~!@1IN2&wE+PG!(kKPHh_FNL17uk z$l>-g`QvTF(SxAueZ|%LeU_s?K9+Dmx!$@oM-u~gknjJZ=R7kFP_Cg}m0qlN|)fWFBH8N|P$=ztBlDTQIbZ980Y$79L{QbB?R9UIw(F;Dab6_g3 zRadHO!YMd%aqAKCa3FJJSzuKbhug7v-*=Y8%@%;cZACa|MBRWnmK^e~wZa5)C%ipm z_tV3@fYBZHYti^(jp}H3rVV z7gY<9KP$T~lhaYwP?rLTr1?kG4EO8z&KqMxR!Yntf89-#7i{z&$=zGnH+DnL$saUM zh=(M^FHa^jJw>4*tQm}2Y20-q(P#@h6#)2C=OqaS*Vb;2LQL!29c@9?CgCtFFcf53E zza3n|l0C-2<#^}DKx6%7D4{mgi`YNc#zPm(Yxg$`8g-mO3eVTU18roc!jzu$1f|E% zwZ&+2>xT6dts;3>PL;L^D&lg-)`jfdZ`tHbC3x4KDTcgxR~Q05PDo z1Wl4>M5jNjny>*BP3Nm=i#_>$%$g))hq4hW2t)r=ku4m6d?P?_?s~Dk#Cc&lvoT;< zyHPFAkyL?<*%2fc!l^K6jIZ>LkgTV>I~N&w>#n2?#XCBp0PSE;m;48;vIt>#YY_x& zsT#M*Z1`0vHaA1bmII3+Wxa-Dr9W@$Hnfy+K-kry_|la9p5{7AclMrR>SqQLk@T& z^;uzMQ#v<^c_l%?L?TXW6uOj8lP77M7Hi=`h_Oe^3q$8#fJtl0rm_%!;e=~6SWlIx5whae zop?Z4Y8)|D{)-O*Sw${}PJGz;R)$uFyR4=qP6*Wr;@sSTA(CtZQ?R50xw=q=vf{_% z+t`Sz;GHcTU7J@Y?ZwFfm=g7O=|5q9#fNr-siL=mU90y&77Ag?NEeRIGWdIcNOX;pLhl?c*;-{pD#Z|l89-F>-6DLvS z1j8VcXP}+6_`nyfa_A;2%P?46@K+miq9*tTt>V>{_M>Daby+ji0&+qTV)?R0E+ z@MOL-Yu(%Po7;2e$y&i5S^M>=y=&L0bIz`MPl;><28NB=jZ3547LW3d2wrnLcJ5s7 z1K7~Q(Gm^#V||W-N@H}CQwVe@qCr^+&VV`A2yx}=+p6Lp8IA!dJnJSPqNkpqApIdj z)QezWbk|WyQkl$MVatJ8T3cK{INnfRs%|LAR!-7s)Ng7{C;D?YNOYQRdQl)Rafp|! zg(Z&4&HD<9Dym@LQ(yT9GBqk+Ea}^Olu#&FWPn)4pr7YDyNX*T)Sb~FBkG~2V2^ZS zB0wta7x(nqTY}rVj+l^V-lS*1BYZ4Gfw@k`fHLvLBX#xOBh#9 z9ioQM4LbVp6#65~DDGwiBE+9>c2KSFV9ipWp*FnGeE%q+8f^J{yH z!^6KScnUai?v$*E%PaU1LfKaxOx_ObHDYnf%2em;YDiXR^Y9L++k{mc6@1yU?;nJr zJ4awIuKvFNY~+_!vy8jQ5R)|m7dBgh`c#j~!E-MBoiZ$wg~&rNI8}Afmq*HCx@7)Q zFBof=R++i&jmaqk)*r?-Oixo6d^1+nK8r!>8@{(se_@d)tHC3Fi+=dKaZ%TxoDS_% zqO{4&RV2?j2|oTS8%d6S^VKdU*EtAZ+H0^sc`BeZ)VT@!j;^}p5k7%nj?DUM`ll@> zVTI@YBG94lO8DZZw$$7;x{2BSr?nTIWohY=1-PSzI8WZe(?xJG0|IVf3cA^PKH6@< zCc=H`0&E815}MT_xiZ=TQln1^PR z+7Xjd(MHVe*=>%!D?;RZl)ZjHy8|v{PBr<J%a8ax{)foDMS`B$Eih|;aMO|Mz#5e3eL1Jy4L{oi z92^I2g?U(%eB%GK>9nbgINQVegC&uJ(P6?!h@;40rkFtVj5D&rb7|NP9#z^KzYBWAU{l{c5I$0VTy-37Q(m=#T8PZc$8qtXpG zpQzp{8g0@Bw^j$aQKUdR$E-Jbdo?QNhT>~7bRU5=%My=$IkjBl!yv?TlKldgcb$<& zo7GK;Do`2$>iS6;Vm6A|(r0?Mj%T|H8MUs3q*B6x_^25+8eZ^Zqq!X4&o|MaJ_B`g z2Z*ahE#P8A*fw`^hO9b@JomztV$NQ>`pZ_MEJ}tZ@_vy5*FoQj#x4QRGyqL}rm~XZ zlAJN93Bs#^iipJdpKqHheeo7eTwRBi-%mF%8p;&}P|=>KZjdXHevaF6u2p}}@8zRv zxt6R4sC_}!s@Jh#g4YLc2|!p0%4;~MFcvKL!6gWR4A}~8uSDrSG*u8qtF8ow4MJy3 zyk26wRJO{YIUXH*vV~6jYt9n}Mw>ELGr!0A5O>x4zOPn(%Wa14%Z3vGyN`K90_R$< z^QtSFwtZYK`lgpLvv}AUH_s)^qpPj;obPBl5M}zoJN~NM;S82SE)=e2uA8BXtBphFz#_tAHg_6t0YX2mkU1-Nz_MT{ z6QfVTAu&xj_^##D#@5%Q`OEpnB@}G`LGD3o-%w_C?ga@8C4zk0*=q(>wFM;~^1e}TIM{@(A%mC9d_h6^ z4r81CD9T=n@=yJf|E!^n^_MA`kfquGP`^|^fiNsi2ms*b_PaFiA7d&SSsH)zcl)nt z-Ws=-%W7l%#S^M&w2e560?mkX<9vsNa&*>QQ%yocW?|G~8E{zaa43;lY`wwe#Of=) z*9gZ0{%wiu311^KfVe*i#h6_y{K@R>5(xt{#yGigcNb5@+TljWgftT@E-Qg`yzrH- zgTpzgHK*Gr*pv)rzCAoxq!bbEHSUYuEw-2E7&Sqhm^eM#9w2hqgMN*8#2a$E_-b}H z$BXQ~Lf-xHXykM01Uq}onn#JMyG4%&V>1Tnb_N2)P3)oZoNbsk76R?qwVy*gKz!IE zOe;6+Zh|{wuG}UJIkb5-yLN3Nc!(Ab@l3oXnLxZYEg&yhFmglwfXMWRZKrt?W?O+{ zS9?esORKcq(bFl=Ao;fS5)YfL>#P#4qjNu_LI)YQ&P(}B=58AJis&-vV(SCRHWK&7 z5G6rnEZJ$Y?df|%te7B$ax4QQSVy$M^?G$%jT*$UnDzM(9#{7K_(43`;dw&DMYm$u zh*<$l&0_4S@OfV>jtDba30q{vn+f*tISF&#n45KAUWHTPi>Uz+J21B*wtLE`x_@?? zXwV&GkT>I8N2&t*pQgxy>xab*zcQI$t&%_K-dZy(g{q=3u379>THV!IAqU{5%TZ#Q zzwdGF%_Q2J;1tQDT$A7pecLUNY%8df<`2c>Gx6M;0A3SfH0Rz=kw=O)aD_nP*r!qZ zC@8o@!V^O&kej)c2(v)f1wHjYoe9ixm2?IWOq&jyvMk#?<2!e3;#}uFx&vRQdogK{ z4A=|-j9isF0i8uX&CW&)XdwQa?K(NE6wwZ#uYCumxilA))FQHI<>LStwfAAliS z2$YP6d%xgL$~&`Et}!Yz^^Iy*8HOD0k<12zJ4FJ%=N)m;E`AP5ky@`|ru;IMKAoyY zt5supB9_VxUIvU{%3#8qLEc?$mI?-WTH}tsJIU)M_`H5>c>oHdJVI{i{2FpWOh`_r zQ48SPuiZO1Oqc3yPO7&jZEyku0eV*1jj1)L>%hquEPzDBjvlDcUEL$qkjNmzDLYjH zAMl`a#>YoErPIa3S<__|hdxvVA9un{cHzCmw?U2vkq-bde(tACAgTu=*^XU!1$whH zX?py;=+m$JeHi#U+ifHglp*Z|M@x2xO-_BnK0Cs`W(FGqK{Q8Q0g~o=@5me#@lvBY zZYI~vHc`gYMjeKC_A+Qo#abX6;cMdV%p~A*uudy3NiwwmM15WGs01E8wmw zL7ju<5MVLzKnkpaHbT%arv=CdRy3`&sIrd4*`A~|^SDk68_nJt1Ip8Zx3mT)C|Ym> zoMgC;+?mXMyxFNm*L@qwNG*`T(4=!XaJRoN$d`{#48mS9S7%A!?tMi11YAfj7^k0G z=kOyFz7gMRH-oI)^U0)%lMI7Vi_n}1Z0Kec#iHZYvKKlSmnltoUY5o5Gfn3+3{m}* z83~G>461D%6R)X*Z}yW*&mWu6vlY?m0yXE8N{Drd*0WVm_DsjR%Pwr)=sSj&i&pNB zAutgHil9ho=iON4BWvs0YEuCZ0Vl`cHAY}0nT!lego5Cv2nUgNkOX?-aD9cm5$CHj z#;|LD=+=ay1}=;n7IB__L0U9;L4;d2au~ifa__Rw*TfFK+0tWZk?_KQL%QAfxp%0Y zP_|#8wkxy2n_C1k+u;;3qkYu-XlIK*45`UY7XU&ytQP9LcigGBs&!3J^B03`=4y0Hlb&b24KIa4VO$-8MpOBHZL!=YYW29Xy zE47leMcT`;aC3F1KF!}=vjV&jB~?oZ(nms|CKGTTLt~2Ii#hr@Tfd56Db(F!U&q{OgPPdP|XkGTwL4eCNjm) z&>b2Hx39H{u-@hZ8-ttRDguzOv;BTw9v|G6k^7b0h$^t04llr;8|>1?!b_$7?PwY=-C>(+!it;LXT z%4;g8%?9j?&d!h8;W|qS1Dm6#L=#MYZe{q>3_<>pr>vFj80=8ui8cJMM;Xl1nriN& zh&v;vN0{j(1x3f8AZ-eI1L^j=I<$}=1mk|3+)1AV0r>g?n0x9~>*(f#UQwRkh){yn zaHxPQga8H(F8Ncf?8=L2<>l<%l?nAW10eUE^vxsgtrB+_d8KzbfM0t+%t1NWF4dsv z96yhm@Y=Yt-@E|geII&~V9HKQV9U)H_BKq}Bg71AwF8G9+LC*FQP-@0^z)i4-LXhq zs6T2c+cDGC02j-9-9@&yb-Y$jB0ZzvBE~nMEtU4f8ebJYsGdyqmwWx>dFSjSd}?Cl z|K1RYkNoA^41a=r4U7rt^_3J&k>Ho+q`sF%Z9H1!1TJa6au61JZxQ|3B~@PEyCO2; zK*4-r>_BPjPk8%ni#d8BSBKdBXtH6*_lz@RmAc&UKq=3yr`zzV!xWKcxlD={!kBp& z-*_K=?P*57u*6!Exj!P?v4TIqI8QdR_V`Y8zr~QpG=cARdOhnrOyM=2+o-FJQOM534Uye3lf9pHS~J*(-fMub?qzOGj}Xk)923Ae}tA*}5l;k_5I zw~A`CtH;PAXoBHVQlg8jt>Po!;EqFu5`{C{5-E|LL98!rQ&55RJ(!^Ln*%8a`UsF# zCeYUC`3t3roJvqdwzPXKYs9%wZl;w?_QaNy>&JDvlmQ)q_AG}B!SF&(HO zMWnr5N`pj{jYP{g{uCSTjvqiIsopRto3<>#(f(!t8B=G#anwXZSR`zKJ32Zq}iUa_}dSi{D<{uL{{D@ z!oCZZ<|>SSW2)p*u#Z}6LnWem`*5NRqT0N(i-DNoM;b#<6S_y!(Zvlg z5Rk;&hAV6zR~*L}D#I(=8Ij>hR5w$}pn@R@KDVoGi!4AetL^*hX$Ap=eHsjaJO#M* ziX;RA7exQ+KZ^3X5ixorD&|24ubmgGM7ez^Q~@uoAE607gxn2LG2=Ut?8t%)-h@*4 z5b*cD!^BM87+-A?FhczUPSDO7YE+^z#DSfaBW$>V8@A9Gq&P0zg4jqqpcPdM!4>e` zYIUg9qeDn3F#o+IZvzd9TH$w8DA_g1Dxd7JdJndrn+!`#;TH? zB))(Ck>vXzatQ3%J=!cg8+OQUvC?J&nEw2tgp_2G|~ZkyrVXGDeo1y;ubM!9}10sBu_Qn;=}46gy0@ilBNRYtS8l z+8MBN70STt*@2`XO@8T9fN{>ErPr=$sPPIGv+q`4PK#0nOEyk}!4Mb^m1zmoZt`Mb zwMI;CaX@T%AJX)F3;4Ce*MJaMT&$@~5oQF}5Z1ubOSt6I_GQ2pCcPw}1EKTS?ebx} zBDhB*WtxEz|{43C1doxK^bT!L2SeQ5Rs+5YOf?W^-d@zyG0xdj6wvg{`DPaJJJ! zibeXT@2ox83cz4KQwQKOAu;@?prw%110WK!9(6ZIxUcym~ zrYCpP{<;7qQj}oN4TPIX!FvmpE;!odW^Cr5IvZsNVrdFmTXqhDWT}#RWspOsIv^!L zgvNKOHOi$HBD#w0y4vun05F5p>KG+{#Es2nqA#`rugPFI{;0meDPOB)1dxNsxJFIz z6I8YnpF^^dT^-omGNcBU->`~+TVrqS2@%OrrCS87O{?7qpx2FDUs!Ge?xxG_?D@S^ zOC2aDVW`!BF0ktl(1C@XS9!E7o>41GoP{AOjskQd`SN>!Tr&Cn8HL1%MesZomCuHf zdGP8=et(nP`3E3S|Kn$x>hYh=0uXM)eqEe)n8yHOdZ zrz2Kipfdus2H}x(98q8x9#fM2q#$r+F0!lbWz?jwVnsMj#7hhx7LI=Q_O%RkPCY7n zP)~76Z!)#c$hFdGJ$aI|KL0XewIV);8M8E3AD=iD{(u`^XcLmY+EUR*e8l0<5)*U# zaAE}k!Nif8pk~q%Cc_?ZBs6)aG&qJ3?8>tbS33j;2NC(ab+q1C}JD#mXbzZiy0%K1h5F{zFyA58&8xf$Rc zSgTlyXEt>R(bPb2m~3TGLUek;q&FJ&;7qv>1i{p>S`N)9`!7-^eYG6PI`oNSoTF7+y)d92%FH@BA?&wM}Umznv0?zs|EmYPNr(u2Y^!> z2m#Y&wH?$ov`Q=VYJwLWeTq99EW2mRAshgE)!|iI_!Ybdx7151uim@kAv|I*0L&b9 zZqPlY8AnO45)8))kz1NZ^G13tmDN5kK4NeWoWA)p;rNtB(Zl!Q2ekTH7-oaWO&WLX z;wle-TlC_?Fv|)s;D`cYYky0x_V(IR+X2E7vl9LKZEDP|ckXZHz(g?)gv-485=hP) zMNf>f>}nTzau|T@3Atn0SQgTZ6>F9FUh`vjU*W}aos~M*uod~eq_Ut{FJ4gW?*>pU zp*2Cicn+P$w&3(mk#rSp1CX6E_g$Qb^EwH0qh?%Kt#ND8+@CM9H>RI^3?3x=t6oqn z*><#87*6NM2|V(2gS}~L4X-wyFdYV8`EZ6zp6n~^O}pXQTAYDjwa^HNhncfAh;>(M zo24#H>taL@x;H|W=~`ECShv}&1)CPzhilMXAEexsL3EjWSdCEYbyQv8+SQ&)$|7JGBid#L-OcLQ7xDrpe zERm4=Zn=3s6=ppVW|xX~fH{~@d`~JiP&=CF zUc<-4S28w~pTb#sNF^E2GzZ+uxz_Gj%C>Wz7t6W(e@$N*H_ecj@M{LCp8cmcQ~B9CaxHzB&~ znQyHKi;0IX9*v>#iA>tgaiT;~eivx_gi#O%zNgSev3?L^<*E(v0V>uceH)K`KV@?y z&;+3SbjL($vUjt)U~U9IM>F19;35>q(8CaGg0YDMaBlggaE?*%82qq$D%nWVnw)e) z^6N4lwz@bRFZBEA9ng-)OE%Cy8JYLe9ggtdcARxN=X}M?C&%IR!c{#Q(p2c0Jq@V~Gohv&^8nh4um_l(*#_C}! z%Q(-C0RyHr!0WL}ZSyXKyA7119~^{#EP$5BI%Dn?c&gl=hY4}N9m|xR6Dnwmmb`$8 zS%9t!91T0}ata`$xMYC)WI$?^*qm0}8o}~=7lr$GwvX5PV%w(N z3{G*X`(dHJq20&8>pGCSJ850=01GE-~(QZa7}m!Xe?J zB#A2{kPaA7PSlKFW&|KCBem+ejjYfcdQBRtdvnp9STO=99E5clWAk8W%1%#?&cfYK z4zrJGnbx|}Q!Tx3;>_sn6^~S75GElJDwBGK zC$LL$XH6m!MYo)$X)M+X5A80yd8@3M5(gYcS&^WqP+WfpNjb%Ln^_?jiKa|?9(my@ zbCY9=+%^Bg1F)myM{-6uc2Bdw1=Y8m2KtcKx3`|U4S=_GK5p0R(=9cfOgyJe1;s$? z$oN71?ZohraG~&hX021sO!8&}O9nw^epPLG0mYrL@~$Ohj$qhG1elNV?H(mRzEr`f z92XMdJ|d+-a^RXM*)eG6%m~BahFVJeu&i#xVF`l5^Ailq#a<}vIov~Ad$KG%N8yls zgBsOJ3Zc)t9^1)-_OFb|7O+`HG4LxW@`M*nCHdZJ?0Kb7dtIYKQts04uOZ016X&mp zJxp|k-2FY_x;^9Qw&s;`Ba7obIDNcpGbYQuhTIHeY;675tQ$b?e+qJ%){=6CqZzsGA+c2%DazKVW=>IXSAUbod%OgfMmCf z)Dfe)dd2ye!we9yu3!zV{kY+-=_H3FxX}%q2*rm!46>V2#ALyYlSNrhDbVg_bm^&S z&Xn{~UuF{uTn=r=*6E0)EH*OgJqQXOW)$C>x=^D;8iKOXSBS-^XLHPZ8@%BLQFtiDp=be}mC zuQuMLh_Lyb1JiV%j2k>x;r(SHAKM^aB=6nj@^vJ}_=M z{jl}z zeBjeRR$B;MQMjFXdjan>t+xnEreX;n!&dX~IPW{cREyDV$gMI0NgPT=X7#zoQc?(w zxZJ)Q@;Cdf?V++_Ba(y7*d@>YU zZpHR*ua)0h)a%{1?PoLC71)^InQU^{(J7k!5&@+To3uwZJozg@Q(?^Cd zz3>Mpu1tuN42aYJBxRyo+!uqPU86vI6IbD@I7`JanA?{nbxOlq2pmFRjNT4Y?nG|L zW6|NF)K<<93QfFt{yF=BZJz2G%`W-}MKRVT!@hd_y2V%H`RxkrH{)i?l&_LSDisGA zH`_xA+e3>syk5Ls59cqZhMu>g)LKU8=r+gGtCrbE$ULnKk17amRd8czqv(UU-KESJ zN9HR)ZVVt5oSzeR7#>Lrm&jXR>H@k7zorHp@t3xUdsOeb0J-_mZ*Ed=OBNoq1yB~= zqrUiHoOV4P4axrV?8hG+jMhdyzFdAB1>wZ`Z|5|GY^<#GtPTIG5MP$si88hr(%Y0` zV%QEQ?g^L$P8Ec6wY>fSMmt2jGzwD@%WV8gdPHLzcx)Z7Nse(2(fc*oPZEuJ9u*10 zt7BXKSuHKC#j4WaDjm8XV_8|&=r1H&Ua1xxUtXsGmois46&H5Ud+tM_!bh z9O<+U%A9(<()wAxjgXm2qW#37zel&;r#qiQUXn)Z>Tkmax-E67#0~*(;KE|WQl?NC zN_Tm0g^a=PQ@FWOyS`4p`jZD# zhiY$;DUg=JcYLBBd$tuesBzZPx^9ORSq&Y(NHI;OhF7!eM)|#-R4^91cJ?&^T{qZQ zEdfMI$oM7l%4d3~25yU?8})PkkcuzMiZMfTUt7|?rPH$W_`EJ)zjWB|vc2Kxaq=yC zESy-?gAGyY`^ouBPLSdh9VbHvm6l+N?d{v)I-Ozq%KI&Pd7k=yCJ&{nYyjAp1Nwvt zc<%FdcToXznTwfmd0!HWu8KwMN}8Y%ghb-W8RZt)vOEwhC%nF%9`_lf%6|qMF=5Gz zU6QonYq$zYph&NQAtV$rHZa$Yp0B%K3sxOUC7sN~CgK7`bgVBO8$}j%7aW-pM?)wS zY+$(oN7+MevuJ0d#$_mfH~^K-VPEoKEs}iyrjYuN*6+Yr*};k|HRZ6DK~EZ={qsga zQc6gg{M&+P12e}CSbxA7uy2f)s6~2%aPY*M1?M#_qQM!n%?S5xDOq}rfVAU&|iDC z!lI81C|)Np!5bF1~lyc}bTQ(UGePxwpiE zzu2}O7eREfgXKk1%}(_B5oMj)A6B>dmS|o_qDhC=Ozj*tkv6C48_9jE;t=-^_)Y_>Z1D?T3{&;mjsJw>cv+==xaju z&0vEO6eEJiHNP0B2CsvyP0KmzEPk)9gqM2#s6&TLF1=o6MN7Dc_WFV5S%WRZXrVb) z3mW6VdpplCTv-AuFhW~9A!_J}ail)r?Mg0(ELm8ivHC5BnHw9Hj!w_j*-a+?a}Z>) zD;CKW)%AhYghnX-Re8{pHO!p96kyDD;$7d>(Y!9m`54;P6yBRPXGN6cpz*ksBI<_v z*+ULnO(`c}k*a`Kl?4t}x(e>q-bf>Fs{k7;uziG^o8NYe@QVq1rO;90X-9Bhs_VXqDS$V{KLmpAim`^jtrHgkSJdtGO8bop+(nEW`zN#H?))7(Xn)yxk>>o5z5 zR$n@z;u7(CZwA{T>zY_UrSOWF0W6Fh%^cHXJx)rK6IU{h?`DopYmd&J*0V>ZjZU9! z&hND;$r-73>J$#hFxDb?&i%9(6212+2@8EEp$6BU-jSsxZRRTkq;cDeoVAY+FZWoZ z9q{DUH)05&(#*wSTI2l3&^L6pk>M)0IPtC3g96Vaz}qD&_2XWt*q>H7DHwI z>2sNZ64)F{E#)e6>x3+dx}()+u9AwUm-eRYkqk`D9@DhI?FG+@NL$f_yhya!$1UIM z#rCXN#QkawOCC0jc%Q9K*5xQzzhs*7=s@+#1(N*3t6?y=!PrKFB3s0LX)E#@unO!W z)?=jWQdH8*5*DBbl5eRvYfqkL`b~PzwRe#v`(xt5W~QPdk9hl*CTa>JaP5|opNlmF z)>RoO$;fbNWe*!f-lWcqdGu_r_phQ(+FP+SJ=vPsy;)juIz3#R?z3F3kB6lulf^hB zkl~rd%6tNs6c3yyk)S)!ZZz1_nseN$Ux|>IC0?Bxt+l6!1#@wqU{-}KQ5z2Hp zU=i6QF0Aw+wU?;{x<^-)rK0Pv!2~?}2wb~(inp7shteX5m87cPtVDZZB%&<{hIpQB z3N7fB5%xq&=L-5`H!d?L!42ky-$1rMhuzDBTm+}gaT}~~Ost#)h7_xRlu*P1AU2TgIn zW1P36;ecE4Bzu44fipe!o~{Wmh#ew{0@nO^qeMc#ixy|7zUqYh{_l#`wptdKAN?c% zwmwv?vH#;)YBOs?BiH}d2-Hf^%4UTD;VoOoE~1dsG`3qip9-X~ks*mb2+)GXOu;Z7 zx+Zx^&6Cnd@%`syVxws(-%{(%)A^Od)PT-$u5%_s;1?4N0f8?W6fjNk;m*kUtxz`# zYzh=q)z*%#4?SSA1xzG%7RSo3M8nm74h=#{4d$z$^2w%KXry`pa`rfWUv$$y!Qhbh z)uX^Z2V!&HFrCKaj2`dN(WH&fII?HS&>^pb?6+O`G4Iy}cj+FknniESfECp( zDS5=RzLIo-MR!uQkZ@$b4Eg|+BpM&-yV;{Q?Xwv~dr&bdC$c37K#sa>??aVR!(Gtl zA@R0#=*oVYoSQG8Xko-qQ~#8Mxosfs=Rb1UAz1-|dbjY>1_5zFNmEP}5{s70D4v&m zy^-eP8D#dYCt_3TAms!C|)&*?XfM18(l0V2yta*$yAyfITy~Aq$9!3RqGVosFt`S@XMQq^|)4%Lvd%{ zL|($ghr%Er5FYS;JKeuJke?#cm+R_;~ zIQ%}i{INOD8lPq+KOC-w{NG)<<;#CD<{u>UzYPjQc{#iKh$&_tcKo_M_jLXX$jsm) z5;!=U{9T(?#zhtaKSIkFynpw$PkMiaQZTZ$vHv@M@W<3uR);sHeLVB?(EWeB&HS%0 z|JTC&Z+P1u6eT*YfbI{Uru>L3zxp&-$X`)}Y^;sVO#XYI`GW!+9Y^Wo{gXK(Uf zfy5mhZU6QTeMEMABKf!k418QXiGJCk=qbMi2-uqaFEdspsr+N~qt^o!4FCZ7FPxP0 z-*Uu19yD7T+5bJK%|F<7QBfwA z@A!zG6}bOyOw{mSL4@@j_4M@|{->PaS4r+aU*igA{)+Rbo>9M>vA+`sMArWb@-Lae z-vR!ZtzSh3|7=D2?*V^E4*osRuaZ-L2Hoxc){;L9Q2jmBuaX>pM)9Bg7WF5Aj=x9x zwO!bsNpQe`f00Q4-}3xTNBCo%e(jm_XOaWNZ%My&(D|KJ|JmuV{uOinnYDu<{T1t< zrQyFD{|{U0e<=+Avn}es9sk!d@xRCV_2uTDSwNqDXUngzI{zN)*9Sj;W-0LfC)S@H c1N{{M^dqPF$Ayy|Aot@Y5JK=H_W=0+0B+e$jQ{`u literal 0 HcmV?d00001 diff --git a/v2minimal.php b/v2minimal.php new file mode 100644 index 0000000..b9e0aae --- /dev/null +++ b/v2minimal.php @@ -0,0 +1,5430 @@ +PHP-Version zu alt' + . '' + . '

PHP-Version zu alt

Diese Anwendung benötigt PHP 8.0 oder neuer (gefunden: ' + . htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8') + . '). Bitte im Verwaltungsbereich des Hosters umstellen.

'; + exit; +} + +const SW_CONFIG = [ + 'app_name' => 'Bürgerabstimmung', // Name in Titel und Fußzeile + 'domain' => '', // leer = Host der Anfrage, sonst feste Domain für erzeugte Adressen + + 'show_test_banner' => true, // true/false: gelbes Band „Testbetrieb“ ganz oben + + 'testmode_end' => 'gui', // gui = Umschalten unter /setup erlaubt, edit = nur hier, live = Testbetrieb sofort beenden + + 'eid_mode' => 'demo', // demo = Anmeldung über die Trust-Liste, eid = nur über eigenen eID-Server + + 'authorized_keys_url' => '', // https-Adresse der Trust-Liste mit freigegebenen Ausweis-Schlüsseln, leer = keine + + 'eid_client_url' => 'http://127.0.0.1:24727/eID-Client', // Ausweis-App auf dem Gerät (BSI TR-03124) + + 'eid_server_url' => '', // https-SOAP-Adresse des eigenen eID-Servers (BSI TR-03130) + 'eid_server_cert' => '', // Pfad zum Client-Zertifikat für den eID-Server + 'eid_server_key' => '', // Pfad zum privaten Schlüssel dazu + 'eid_providers' => [ // Anmelde-Knöpfe auf /auth; start = https-Startadresse, leer = Knopf meldet „nicht eingerichtet“ + 'ausweisapp' => ['label' => 'AusweisApp', 'start' => ''], + 'nect' => ['label' => 'Nect Wallet', 'start' => ''], + ], + + 'timezone' => 'Europe/Berlin', // Zeitzone aller angezeigten Daten + 'default_lang' => 'de', // Sprache vor der Auswahl: de oder en + 'langs' => ['de', 'en'], // wählbare Sprachen + + 'jury_share' => 0.01, // Anteil der Nutzer, der je Meldung ausgelost wird + 'jury_min' => 5, // angestrebte Mindestgröße der Jury + 'quorum_share' => 0.005, // Anteil der Nutzer, der für eine gültige Entscheidung stimmen muss + 'quorum_min' => 3, // angestrebte Mindestzahl abgegebener Jurystimmen + 'report_vote_hours' => 24, // Stunden, die eine Jury zum Entscheiden hat + 'jury_cooldown_days' => 3, // Tage, die jemand nach einem Dienst nicht erneut gelost wird + 'reports_per_day' => 3, // Meldungen, die eine Person pro Tag abgeben darf + + 'session_idle_minutes' => 30, // Minuten ohne Aufruf bis zur Abmeldung + 'session_max_hours' => 8, // Stunden bis zur Abmeldung, auch bei Betrieb + + 'page_size' => 20, // Themen je Seite +]; + +final class SW +{ + public static array $cfg = SW_CONFIG; + public static ?Db $db = null; + public static string $dataDir = ''; + public static string $pepper = ''; + public static string $serverSign = ''; + public static ?bool $testMode = null; + public static string $lang = 'de'; + + public static array $tActive = []; + public static ?array $user = null; + public static string $base = ''; + public static string $path = '/'; +} + +final class Clock +{ + public const FORMAT = 'Y-m-d H:i:s'; + private static ?DateTimeImmutable $testNow = null; + private static string $tz = 'Europe/Berlin'; + + public static function setTimezone(string $tz): void + { + self::$tz = $tz; + } + + public static function setTestNow(?DateTimeImmutable $now): void + { + self::$testNow = $now === null ? null : $now->setTimezone(new DateTimeZone('UTC')); + } + + public static function now(): DateTimeImmutable + { + return self::$testNow ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + + public static function nowStr(): string + { + return self::now()->format(self::FORMAT); + } + + public static function localDate(): string + { + return self::now()->setTimezone(new DateTimeZone(self::$tz))->format('Y-m-d'); + } + + public static function nextLocalMidnightUtcStr(): string + { + $local = self::now()->setTimezone(new DateTimeZone(self::$tz)); + return $local->modify('tomorrow')->setTime(0, 0, 0) + ->setTimezone(new DateTimeZone('UTC'))->format(self::FORMAT); + } + + public static function addHoursStr(string $utc, int $hours): string + { + return self::fromStr($utc)->modify(sprintf('%+d hours', $hours))->format(self::FORMAT); + } + + public static function addDaysStr(string $utc, int $days): string + { + return self::fromStr($utc)->modify(sprintf('%+d days', $days))->format(self::FORMAT); + } + + public static function fromStr(string $utc): DateTimeImmutable + { + return new DateTimeImmutable($utc, new DateTimeZone('UTC')); + } + + public static function displayLocal(string $utc, string $format): string + { + return self::fromStr($utc)->setTimezone(new DateTimeZone(self::$tz))->format($format); + } +} + +const SW_SCHEMA = <<<'SQL' +CREATE TABLE IF NOT EXISTS schema_info ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pseudonym_hash TEXT NOT NULL UNIQUE, + lang TEXT NOT NULL DEFAULT 'de' CHECK (lang IN ('de','en')), + is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0,1)), + is_seed INTEGER NOT NULL DEFAULT 0 CHECK (is_seed IN (0,1)), + jury_cooldown_until TEXT, + created_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name_de TEXT NOT NULL, + name_en TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + goal TEXT NOT NULL, + reasoning TEXT NOT NULL, + category_id INTEGER NOT NULL REFERENCES categories(id), + scope_level TEXT NOT NULL CHECK (scope_level IN ('kommune','landkreis','bundesland','bund')), + scope_name TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','closed','removed','archived')), + end_mode TEXT NOT NULL DEFAULT 'date' CHECK (end_mode IN ('date','count','both')), + end_date TEXT, + end_target INTEGER, + created_at TEXT NOT NULL, + created_date TEXT NOT NULL, + UNIQUE (author_id, created_date) +); +CREATE INDEX IF NOT EXISTS ix_topics_status_created ON topics(status, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_topics_category ON topics(category_id); +CREATE INDEX IF NOT EXISTS ix_topics_scope ON topics(scope_level, scope_name); + +CREATE TABLE IF NOT EXISTS votes ( + topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, + voter_tag TEXT NOT NULL, + choice TEXT NOT NULL CHECK (choice IN ('for','against')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (topic_id, voter_tag) +); +CREATE TABLE IF NOT EXISTS favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category','scope','topic')), + ref TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, ref) +); +CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, + reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + criteria TEXT NOT NULL, + freetext TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','voting','decided_removed','decided_kept')), + jury_size INTEGER NOT NULL, + quorum INTEGER NOT NULL, + created_at TEXT NOT NULL, + voting_starts_at TEXT NOT NULL, + decided_at TEXT, + UNIQUE (topic_id, reporter_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS ux_reports_open + ON reports(topic_id) WHERE status IN ('pending','voting'); +CREATE INDEX IF NOT EXISTS ix_reports_status ON reports(status); +CREATE TABLE IF NOT EXISTS report_jurors ( + report_id INTEGER NOT NULL REFERENCES reports(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote TEXT CHECK (vote IN ('confirm','reject','neutral')), + voted_at TEXT, + PRIMARY KEY (report_id, user_id) +); +CREATE INDEX IF NOT EXISTS ix_jurors_user ON report_jurors(user_id); +CREATE TABLE IF NOT EXISTS rate_limits ( + k TEXT PRIMARY KEY, + window_start INTEGER NOT NULL, + cnt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS eid_flows ( + nonce TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + eid_ref TEXT, + created_at INTEGER NOT NULL +); +SQL; + +final class Db +{ + private PDO $pdo; + + public function __construct(string $path) + { + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + throw new RuntimeException('Datenverzeichnis nicht anlegbar.'); + } + $this->pdo = new PDO('sqlite:' . $path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]); + $this->pdo->exec('PRAGMA journal_mode = WAL'); + $this->pdo->exec('PRAGMA foreign_keys = ON'); + $this->pdo->exec('PRAGMA busy_timeout = 5000'); + $this->pdo->exec('PRAGMA synchronous = NORMAL'); + } + + public function run(string $sql, array $params = []): PDOStatement + { + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $type = is_int($value) ? PDO::PARAM_INT : (is_null($value) ? PDO::PARAM_NULL : PDO::PARAM_STR); + $stmt->bindValue(is_int($key) ? $key + 1 : $key, $value, $type); + } + $stmt->execute(); + return $stmt; + } + + public function one(string $sql, array $params = []): ?array + { + $row = $this->run($sql, $params)->fetch(); + return $row === false ? null : $row; + } + + public function all(string $sql, array $params = []): array + { + return $this->run($sql, $params)->fetchAll(); + } + + public function val(string $sql, array $params = []) + { + $v = $this->run($sql, $params)->fetchColumn(); + return $v === false ? null : $v; + } + + public function lastId(): int + { + return (int) $this->pdo->lastInsertId(); + } + + public function tx(callable $fn) + { + if ($this->pdo->inTransaction()) { + return $fn(); + } + $this->pdo->beginTransaction(); + try { + $result = $fn(); + $this->pdo->commit(); + return $result; + } catch (Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } + + public function migrate(): void + { + + $exists = $this->val("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_info'"); + $this->pdo->exec(SW_SCHEMA); + if ($exists === null) { + $this->run("INSERT INTO schema_info (k, v) VALUES ('version', '1'), ('created_at', ?)", [Clock::nowStr()]); + } + $favSql = (string) ($this->val("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'favorites'") ?? ''); + if ($favSql !== '' && strpos($favSql, "'topic'") === false) { + $this->pdo->exec( + "CREATE TABLE favorites_new ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category','scope','topic')), + ref TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, ref) + ); + INSERT INTO favorites_new SELECT user_id, kind, ref, created_at FROM favorites; + DROP TABLE favorites; + ALTER TABLE favorites_new RENAME TO favorites;" + ); + } + } +} + +const SW_HTACCESS_DENY = <<<'TXT' + + Require all denied + + + Order allow,deny + Deny from all + +TXT; + +const SW_HTACCESS_ROOT = <<<'TXT' + +Options -Indexes -MultiViews +DirectoryIndex index.php + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + + Require all denied + + + Order allow,deny + Deny from all + + + + + Require all denied + + + Order allow,deny + Deny from all + + +TXT; + +function sw_setup(): void +{ + Clock::setTimezone((string) SW::$cfg['timezone']); + date_default_timezone_set('UTC'); + error_reporting(E_ALL); + ini_set('display_errors', '0'); + ini_set('log_errors', '1'); + + SW::$dataDir = __DIR__ . '/data'; + if (!is_dir(SW::$dataDir) && !mkdir(SW::$dataDir, 0750, true) && !is_dir(SW::$dataDir)) { + throw new RuntimeException('Verzeichnis data/ nicht anlegbar.'); + } + ini_set('error_log', SW::$dataDir . '/php-error.log'); + + $dataHt = SW::$dataDir . '/.htaccess'; + if (!is_file($dataHt)) { + @file_put_contents($dataHt, SW_HTACCESS_DENY . "\n", LOCK_EX); + } + + $rootHt = __DIR__ . '/.htaccess'; + if (!is_file($rootHt)) { + @file_put_contents($rootHt, SW_HTACCESS_ROOT . "\n", LOCK_EX); + } + $robots = __DIR__ . '/robots.txt'; + if (!is_file($robots)) { + @file_put_contents($robots, "User-agent: *\nDisallow: /\n", LOCK_EX); + } + + $keyFile = SW::$dataDir . '/secret.key'; + if (!is_file($keyFile)) { + if (file_put_contents($keyFile, bin2hex(random_bytes(32)), LOCK_EX) === false) { + throw new RuntimeException('Geheimnis-Datei nicht schreibbar.'); + } + @chmod($keyFile, 0600); + } + $pepper = trim((string) file_get_contents($keyFile)); + if (strlen($pepper) < 32) { + throw new RuntimeException('Geheimnis-Datei beschädigt.'); + } + SW::$pepper = $pepper; + + if (card_supports_sodium()) { + $srvFile = SW::$dataDir . '/server_sign.key'; + if (!is_file($srvFile)) { + $pair = sodium_crypto_sign_keypair(); + if (file_put_contents($srvFile, base64_encode(sodium_crypto_sign_secretkey($pair)), LOCK_EX) === false) { + throw new RuntimeException('Server-Signaturschlüssel nicht schreibbar.'); + } + @chmod($srvFile, 0600); + } + SW::$serverSign = base64_decode(trim((string) file_get_contents($srvFile)), true) ?: ''; + } + + setup_apply(setup_load()); + + $dbPath = getenv('BUERGERABSTIMMUNG_DB') ?: SW::$dataDir . '/buergerabstimmung.sqlite'; + SW::$db = new Db($dbPath); + SW::$db->migrate(); + sw_seed_categories(); + + if ((string) SW::$cfg['testmode_end'] === 'live' && test_mode()) { + test_mode_end(); + } +} + +const SW_SETUP_KEYS = [ + 'eid_mode', 'eid_server_url', 'eid_server_cert', 'eid_server_key', + 'eid_client_url', 'authorized_keys_url', 'nect_start', +]; + +function setup_file(): string +{ + return SW::$dataDir . '/config.yaml'; +} + +function setup_token_file(): string +{ + return SW::$dataDir . '/setup.token'; +} + +function setup_token(): string +{ + $file = setup_token_file(); + if (!is_file($file)) { + @file_put_contents($file, bin2hex(random_bytes(16)) . "\n", LOCK_EX); + @chmod($file, 0600); + } + return trim((string) @file_get_contents($file)); +} + +function setup_load(): array +{ + $file = setup_file(); + if (!is_file($file)) { + return []; + } + $out = []; + foreach (explode("\n", (string) file_get_contents($file)) as $line) { + if (preg_match('/^([a-z_]+):\s*"(.*)"\s*$/', trim($line), $m) !== 1) { + continue; + } + if (in_array($m[1], SW_SETUP_KEYS, true)) { + $out[$m[1]] = str_replace(['\\"', '\\\\'], ['"', '\\'], $m[2]); + } + } + return $out; +} + +function setup_save(array $values): bool +{ + $lines = []; + foreach (SW_SETUP_KEYS as $key) { + if (!isset($values[$key])) { + continue; + } + $value = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $values[$key]); + $lines[] = $key . ': "' . $value . '"'; + } + $ok = @file_put_contents(setup_file(), implode("\n", $lines) . "\n", LOCK_EX) !== false; + if ($ok) { + @chmod(setup_file(), 0600); + } + return $ok; +} + +function setup_apply(array $values): void +{ + foreach ($values as $key => $value) { + if ($key === 'nect_start') { + $providers = SW::$cfg['eid_providers']; + $providers['nect']['start'] = (string) $value; + SW::$cfg['eid_providers'] = $providers; + continue; + } + if (in_array($key, SW_SETUP_KEYS, true)) { + SW::$cfg[$key] = $value; + } + } +} + +function setup_read_post(): array +{ + return [ + 'eid_mode' => post_str('eid_mode', 10) === 'eid' ? 'eid' : 'demo', + 'eid_server_url' => post_str('eid_server_url', 300), + 'eid_server_cert' => post_str('eid_server_cert', 300), + 'eid_server_key' => post_str('eid_server_key', 300), + 'eid_client_url' => post_str('eid_client_url', 200), + 'authorized_keys_url' => post_str('authorized_keys_url', 300), + 'nect_start' => post_str('nect_start', 300), + ]; +} + +function setup_validate(array $in): array +{ + $errors = []; + if ($in['eid_client_url'] === '') { + $in['eid_client_url'] = (string) SW_CONFIG['eid_client_url']; + } + if (preg_match('#^https?://[^\s"\']+$#', $in['eid_client_url']) !== 1) { + $errors[] = 'setup.err_client'; + } + foreach (['eid_server_url', 'authorized_keys_url', 'nect_start'] as $key) { + if ($in[$key] !== '' && preg_match('#^https://[^\s"\']+$#', $in[$key]) !== 1) { + $errors[] = 'setup.err_https'; + } + } + foreach (['eid_server_cert', 'eid_server_key'] as $key) { + if ($in[$key] !== '' && !is_readable($in[$key])) { + $errors[] = 'setup.err_file'; + } + } + if ($in['eid_mode'] === 'eid' && $in['eid_server_url'] === '') { + $errors[] = 'setup.err_server_missing'; + } + if ($in['eid_mode'] === 'demo' && $in['authorized_keys_url'] === '' && authorized_count_stable() === 0) { + $errors[] = 'setup.err_list_empty'; + } + return [array_values(array_unique($errors)), $in]; +} + +function setup_probe(array $cfg): bool +{ + return eid_server_useid($cfg) !== null; +} + +function setup_probe_list(string $url): ?int +{ + if ($url === '') { + return authorized_count_stable(); + } + if (preg_match('#^https://#', $url) !== 1) { + return null; + } + $ctx = stream_context_create([ + 'http' => ['timeout' => 8], + 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], + ]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + return null; + } + $found = preg_match_all('/[0-9a-fA-F]{64,128}/', $body, $mm); + return $found === false ? null : $found; +} + +function setup_check_stamp(array $values): string +{ + return sw_hmac('setup-check|' . implode('|', [ + (string) ($values['eid_mode'] ?? ''), + (string) ($values['eid_server_url'] ?? ''), + (string) ($values['eid_server_cert'] ?? ''), + (string) ($values['eid_server_key'] ?? ''), + (string) ($values['authorized_keys_url'] ?? ''), + ])); +} + +function setup_checked(array $values): bool +{ + return hash_equals((string) ($_SESSION['setup_check'] ?? ''), setup_check_stamp($values)); +} + +function setup_ready(): array +{ + $htaccess = is_file(__DIR__ . '/.htaccess'); + return [ + ['setup.check_data', is_writable(SW::$dataDir)], + ['setup.check_https', sw_is_https()], + ['setup.check_sodium', card_supports_sodium()], + ['setup.check_htaccess', $htaccess], + ]; +} + +function sw_hmac(string $value): string +{ + return hash_hmac('sha256', $value, SW::$pepper); +} + +function test_mode(): bool +{ + if (SW::$testMode === null) { + SW::$testMode = SW::$db !== null + && (string) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'test_mode'") ?? '1') === '1'; + } + return SW::$testMode; +} + +function test_mode_end(): void +{ + SW::$db->tx(function (): void { + SW::$db->run('DELETE FROM report_jurors'); + SW::$db->run('DELETE FROM reports'); + SW::$db->run('DELETE FROM votes'); + SW::$db->run('DELETE FROM favorites'); + SW::$db->run('DELETE FROM topics'); + SW::$db->run('DELETE FROM users WHERE is_system = 0'); + SW::$db->run('DELETE FROM rate_limits'); + SW::$db->run('DELETE FROM eid_flows'); + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('test_mode', '0') + ON CONFLICT(k) DO UPDATE SET v = '0'" + ); + }); + SW::$testMode = false; + + authorized_remove(test_keys_load()); + @unlink(test_keys_file()); + log_line('SECURITY', 'test_mode_ended', []); +} + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function t(string $key, array $repl = []): string +{ + $text = SW::$tActive[$key] ?? SW_DE[$key] ?? $key; + foreach ($repl as $name => $value) { + $text = str_replace('{' . $name . '}', (string) $value, $text); + } + return $text; +} + +function num(int $n): string +{ + return SW::$lang === 'de' ? number_format($n, 0, ',', '.') : number_format($n); +} + +function base_path(): string +{ + return SW::$base; +} + +function url(string $path): string +{ + $full = base_path() . $path; + return $full === '' ? '/' : $full; +} + +function redirect(string $path): void +{ + if ($path === '' || $path[0] !== '/' || strpos($path, '//') === 0) { + $path = '/'; + } + header('Location: ' . url($path), true, 303); + exit; +} + +function post_str(string $key, int $maxLen, bool $multiline = false): string +{ + $value = $_POST[$key] ?? ''; + if (!is_string($value) || !mb_check_encoding($value, 'UTF-8')) { + return ''; + } + $pattern = $multiline ? '/[^\P{C}\n\t]/u' : '/\p{C}/u'; + $value = trim((string) preg_replace($pattern, '', $value)); + return mb_strlen($value) > $maxLen ? mb_substr($value, 0, $maxLen) : $value; +} + +function post_int(string $key): ?int +{ + $value = $_POST[$key] ?? null; + return (is_string($value) && preg_match('/^\d{1,10}$/', $value) === 1) ? (int) $value : null; +} + +function post_str_list(string $key, array $allowed): array +{ + $values = $_POST[$key] ?? []; + if (!is_array($values)) { + return []; + } + $out = []; + foreach ($values as $value) { + if (is_string($value) && in_array($value, $allowed, true)) { + $out[] = $value; + } + } + return array_values(array_unique($out)); +} + +function query_str(string $key, int $maxLen = 120): string +{ + $value = $_GET[$key] ?? ''; + if (!is_string($value) || !mb_check_encoding($value, 'UTF-8')) { + return ''; + } + $value = trim((string) preg_replace('/\p{C}/u', '', $value)); + return mb_strlen($value) > $maxLen ? mb_substr($value, 0, $maxLen) : $value; +} + +function query_int(string $key, int $min, int $max, int $default): int +{ + $value = $_GET[$key] ?? null; + if (is_string($value) && preg_match('/^\d{1,9}$/', $value) === 1) { + return max($min, min($max, (int) $value)); + } + return $default; +} + +function log_line(string $level, string $event, array $context = []): void +{ + $line = sprintf( + "%s %s %s %s\n", + Clock::nowStr(), + $level, + $event, + $context === [] ? '' : json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + @file_put_contents(SW::$dataDir . '/app.log', $line, FILE_APPEND | LOCK_EX); +} + +function sw_is_https(): bool +{ + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443 + || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; +} + +function consent_given(): bool +{ + return (string) ($_COOKIE['sw_consent'] ?? '') === '1'; +} + +function consent_set(): void +{ + setcookie('sw_consent', '1', [ + 'expires' => time() + 31536000, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + +function consent_return(): string +{ + $to = query_str('to', 120); + return preg_match('#^/(topic/\d{1,10}|imprint|privacy)?$#', $to) === 1 && $to !== '' ? $to : '/'; +} + +function lang_from_browser(): string +{ + $raw = (string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''); + $first = strtolower(substr(trim(explode(',', $raw)[0]), 0, 2)); + return in_array($first, (array) SW::$cfg['langs'], true) ? $first : (string) SW::$cfg['default_lang']; +} + +function session_boot(): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + if (!consent_given()) { + $GLOBALS['_SESSION'] = []; + return; + } + session_name('sw_session'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + $now = time(); + $idle = isset($_SESSION['last_activity']) + && ($now - (int) $_SESSION['last_activity']) > (int) SW::$cfg['session_idle_minutes'] * 60; + $expired = isset($_SESSION['auth_time']) + && ($now - (int) $_SESSION['auth_time']) > (int) SW::$cfg['session_max_hours'] * 3600; + if (($idle || $expired) && isset($_SESSION['user_id'])) { + unset($_SESSION['user_id'], $_SESSION['auth_time']); + session_regenerate_id(true); + flash('info', 'flash.session_expired'); + } + $_SESSION['last_activity'] = $now; +} + +function flash(string $type, string $key, array $repl = []): void +{ + $_SESSION['flash'][] = ['type' => $type, 'key' => $key, 'repl' => $repl]; +} + +function take_flashes(): array +{ + $flashes = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + return is_array($flashes) ? $flashes : []; +} + +function csrf_field(): string +{ + $token = bin2hex(random_bytes(16)); + $list = isset($_SESSION['ot']) && is_array($_SESSION['ot']) ? $_SESSION['ot'] : []; + $list[$token] = time(); + if (count($list) > 40) { + $list = array_slice($list, -40, null, true); + } + $_SESSION['ot'] = $list; + return ''; +} + +function csrf_ok(): bool +{ + $sent = $_POST['_csrf'] ?? ''; + if (!is_string($sent) || !isset($_SESSION['ot']) || !is_array($_SESSION['ot']) || !isset($_SESSION['ot'][$sent])) { + return false; + } + unset($_SESSION['ot'][$sent]); + return true; +} + +function rate_allow(string $key, int $max, int $windowSeconds): bool +{ + $now = Clock::now()->getTimestamp(); + return SW::$db->tx(function () use ($key, $max, $windowSeconds, $now): bool { + $row = SW::$db->one('SELECT window_start, cnt FROM rate_limits WHERE k = ?', [$key]); + if ($row === null || ($now - (int) $row['window_start']) >= $windowSeconds) { + SW::$db->run( + 'INSERT INTO rate_limits (k, window_start, cnt) VALUES (?, ?, 1) + ON CONFLICT(k) DO UPDATE SET window_start = excluded.window_start, cnt = 1', + [$key, $now] + ); + return true; + } + if ((int) $row['cnt'] >= $max) { + return false; + } + SW::$db->run('UPDATE rate_limits SET cnt = cnt + 1 WHERE k = ?', [$key]); + return true; + }); +} + +function rate_gc(): void +{ + SW::$db->run('DELETE FROM rate_limits WHERE window_start < ?', [Clock::now()->getTimestamp() - 86400]); +} + +function ip_key(): string +{ + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + return substr(sw_hmac('ip|' . Clock::localDate() . '|' . $ip), 0, 24); +} + +function card_supports_sodium(): bool +{ + return function_exists('sodium_crypto_sign_keypair'); +} + +function authorized_file(): string +{ + return SW::$dataDir . '/authorized_keys.yaml'; +} + +function authorized_load(): array +{ + $file = authorized_file(); + if (!is_file($file)) { + return []; + } + $set = []; + foreach (preg_split('/\r?\n/', (string) file_get_contents($file)) as $line) { + if (preg_match('/["\x27]?([0-9a-fA-F]{64,128})["\x27]?\s*$/', trim($line), $m) === 1 + && strpos(trim($line), '-') === 0) { + $set[strtolower($m[1])] = true; + } + } + return $set; +} + +function authorized_contains(string $pkHex): bool +{ + return isset(authorized_load()[strtolower($pkHex)]); +} + +function authorized_count(): int +{ + return count(authorized_load()); +} + +function test_keys_file(): string +{ + return SW::$dataDir . '/test_keys.list'; +} + +function test_keys_load(): array +{ + $file = test_keys_file(); + if (!is_file($file)) { + return []; + } + $out = []; + foreach (preg_split('/\r?\n/', (string) file_get_contents($file)) as $line) { + $hex = strtolower(trim($line)); + if (preg_match('/^[0-9a-f]{64,128}$/', $hex) === 1) { + $out[] = $hex; + } + } + return $out; +} + +function test_key_add(string $pkHex): void +{ + @file_put_contents(test_keys_file(), strtolower($pkHex) . "\n", FILE_APPEND | LOCK_EX); + @chmod(test_keys_file(), 0600); +} + +function authorized_count_stable(): int +{ + $set = authorized_load(); + foreach (test_keys_load() as $hex) { + unset($set[$hex]); + } + return count($set); +} + +function authorized_remove(array $pkHexList): int +{ + $set = authorized_load(); + $removed = 0; + foreach ($pkHexList as $hex) { + $hex = strtolower(trim($hex)); + if (isset($set[$hex])) { + unset($set[$hex]); + $removed++; + } + } + if ($removed > 0) { + authorized_write($set, 'cleanup'); + } + return $removed; +} + +function authorized_add(array $pkHexList, string $source): int +{ + $set = authorized_load(); + $added = 0; + foreach ($pkHexList as $hex) { + $hex = strtolower(trim($hex)); + if (preg_match('/^[0-9a-f]{64,128}$/', $hex) === 1 && !isset($set[$hex])) { + $set[$hex] = true; + $added++; + } + } + authorized_write($set, $source); + return $added; +} + +function authorized_write(array $set, string $source): void +{ + $y = "buergerabstimmung_authorized_keys:\n"; + $y .= " hinweis: \"Oeffentliche Schluessel autorisierter Ausweise. Nur diese koennen sich anmelden.\"\n"; + $y .= " aktualisiert: \"" . Clock::nowStr() . "\"\n"; + $y .= " quelle: \"" . str_replace('"', '', $source) . "\"\n"; + $y .= " schluessel:\n"; + if ($set === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach (array_keys($set) as $hex) { + $y .= " - \"" . $hex . "\"\n"; + } + @file_put_contents(authorized_file(), $y, LOCK_EX); + @chmod(authorized_file(), 0640); +} + +function card_load(): ?array +{ + $raw = $_SESSION['card'] ?? ''; + if (!is_string($raw) || $raw === '') { + return null; + } + $secret = base64_decode($raw, true); + if ($secret === false) { + return null; + } + if (card_supports_sodium()) { + if (strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + return null; + } + return ['secret' => $secret, 'pk' => sodium_crypto_sign_publickey_from_secretkey($secret)]; + } + if (strlen($secret) !== 32) { + return null; + } + return ['secret' => $secret, 'pk' => hash('sha256', 'pk|' . $secret, true)]; +} + +function card_create(): array +{ + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($pair); + $pk = sodium_crypto_sign_publickey($pair); + } else { + $secret = random_bytes(32); + $pk = hash('sha256', 'pk|' . $secret, true); + } + $_SESSION['card'] = base64_encode($secret); + return ['secret' => $secret, 'pk' => $pk]; +} + +function card_forget(): void +{ + unset($_SESSION['card']); +} + +function card_identity(array $card): string +{ + return bin2hex($card['pk']); +} + +const SW_SLOT_SECONDS = 300; +const SW_AUTH_SLOTS = 2; + +function time_slot(): int +{ + return intdiv(Clock::now()->getTimestamp(), SW_SLOT_SECONDS); +} + +function card_seal(array $card, string $action): string +{ + $payload = json_encode(['a' => $action, 'slot' => time_slot(), 'n' => bin2hex(random_bytes(8))]); + if (card_supports_sodium()) { + return sodium_crypto_sign($payload, $card['secret']); + } + + return $payload . '.' . hash('sha256', 'seal|' . $card['pk'] . '|' . $payload); +} + +function card_open(string $pk, string $sealed, string $action): bool +{ + if (card_supports_sodium()) { + $payload = sodium_crypto_sign_open($sealed, $pk); + if ($payload === false) { + return false; + } + } else { + $dot = strrpos($sealed, '.'); + if ($dot === false) { + return false; + } + $payload = substr($sealed, 0, $dot); + $mac = substr($sealed, $dot + 1); + if (!hash_equals(hash('sha256', 'seal|' . $pk . '|' . $payload), $mac)) { + return false; + } + } + $data = json_decode($payload, true); + if (!is_array($data) || ($data['a'] ?? '') !== $action) { + return false; + } + $slot = (int) ($data['slot'] ?? -1); + $current = time_slot(); + return $slot === $current || $slot === $current - 1; +} + +function auth_login(string $pseudonymHash): array +{ + $now = Clock::nowStr(); + $user = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pseudonymHash]); + if ($user === null) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, created_at, last_login_at) VALUES (?, ?, ?, ?)', + [$pseudonymHash, (string) SW::$cfg['default_lang'], $now, $now] + ); + $user = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pseudonymHash]); + } else { + SW::$db->run('UPDATE users SET last_login_at = ? WHERE id = ?', [$now, (int) $user['id']]); + } + session_regenerate_id(true); + $_SESSION['user_id'] = (int) $user['id']; + $_SESSION['auth_time'] = time(); + SW::$user = $user; + return $user; +} + +function auth_logout(): void +{ + unset($_SESSION['user_id'], $_SESSION['auth_time']); + session_regenerate_id(true); + SW::$user = null; +} + +function auth_user(): ?array +{ + if (SW::$user !== null) { + return SW::$user; + } + $id = $_SESSION['user_id'] ?? null; + if (!is_int($id)) { + return null; + } + + $slot = $_SESSION['auth_slot'] ?? null; + if (!test_mode() && is_int($slot) && (time_slot() - $slot) >= SW_AUTH_SLOTS) { + unset($_SESSION['user_id'], $_SESSION['auth_time'], $_SESSION['auth_slot']); + card_forget(); + session_regenerate_id(true); + flash('info', 'flash.auth_expired'); + return null; + } + SW::$user = SW::$db->one('SELECT * FROM users WHERE id = ? AND is_system = 0', [$id]); + return SW::$user; +} + +function require_user(): array +{ + $user = auth_user(); + if ($user === null) { + flash('info', 'flash.login_required'); + redirect('/auth'); + } + return $user; +} + +function card_confirm_ok(array $user, ?array $card, string $action): bool +{ + if (test_mode()) { + return true; + } + if ($card === null) { + return false; + } + if (!hash_equals((string) $user['pseudonym_hash'], card_identity($card))) { + return false; + } + return card_open($card['pk'], card_seal($card, $action), $action); +} + +function require_card(array $user): void +{ + $action = 'confirm:' . SW::$path; + if (!card_confirm_ok($user, card_load(), $action)) { + log_line('SECURITY', 'card_confirm_failed', []); + flash('error', 'flash.card_required'); + redirect('/auth'); + } +} + +function short_id(array $user): string +{ + return strtoupper(substr((string) $user['pseudonym_hash'], 0, 8)); +} + +const SW_TITLE_MIN = 8; +const SW_TITLE_MAX = 120; +const SW_GOAL_MIN = 10; +const SW_GOAL_MAX = 500; +const SW_REASONING_MIN = 10; +const SW_REASONING_MAX = 4000; + +const SW_REGIONS = [ + 'Baden-Württemberg' => ['Alb-Donau-Kreis', 'Baden-Baden (Stadt)', 'Bodenseekreis', 'Enzkreis', 'Freiburg im Breisgau (Stadt)', 'Heidelberg (Stadt)', 'Heilbronn (Stadt)', 'Hohenlohekreis', 'Karlsruhe (Stadt)', 'Landkreis Biberach', 'Landkreis Breisgau-Hochschwarzwald', 'Landkreis Böblingen', 'Landkreis Calw', 'Landkreis Emmendingen', 'Landkreis Esslingen', 'Landkreis Freudenstadt', 'Landkreis Göppingen', 'Landkreis Heidenheim', 'Landkreis Heilbronn', 'Landkreis Karlsruhe', 'Landkreis Konstanz', 'Landkreis Ludwigsburg', 'Landkreis Lörrach', 'Landkreis Rastatt', 'Landkreis Ravensburg', 'Landkreis Reutlingen', 'Landkreis Rottweil', 'Landkreis Schwäbisch Hall', 'Landkreis Sigmaringen', 'Landkreis Tuttlingen', 'Landkreis Tübingen', 'Landkreis Waldshut', 'Main-Tauber-Kreis', 'Mannheim (Stadt)', 'Neckar-Odenwald-Kreis', 'Ortenaukreis', 'Ostalbkreis', 'Pforzheim (Stadt)', 'Rems-Murr-Kreis', 'Rhein-Neckar-Kreis', 'Schwarzwald-Baar-Kreis', 'Stuttgart (Stadt)', 'Ulm (Stadt)', 'Zollernalbkreis'], + 'Bayern' => ['Amberg (Stadt)', 'Ansbach (Stadt)', 'Aschaffenburg (Stadt)', 'Augsburg (Stadt)', 'Bamberg (Stadt)', 'Bayreuth (Stadt)', 'Coburg (Stadt)', 'Erlangen (Stadt)', 'Fürth (Stadt)', 'Hof (Stadt)', 'Ingolstadt (Stadt)', 'Kaufbeuren (Stadt)', 'Kempten (Allgäu) (Stadt)', 'Landkreis Aichach-Friedberg', 'Landkreis Altötting', 'Landkreis Amberg-Sulzbach', 'Landkreis Ansbach', 'Landkreis Aschaffenburg', 'Landkreis Augsburg', 'Landkreis Bad Kissingen', 'Landkreis Bad Tölz-Wolfratshausen', 'Landkreis Bamberg', 'Landkreis Bayreuth', 'Landkreis Berchtesgadener Land', 'Landkreis Cham', 'Landkreis Coburg', 'Landkreis Dachau', 'Landkreis Deggendorf', 'Landkreis Dillingen a.d.Donau', 'Landkreis Dingolfing-Landau', 'Landkreis Donau-Ries', 'Landkreis Ebersberg', 'Landkreis Eichstätt', 'Landkreis Erding', 'Landkreis Erlangen-Höchstadt', 'Landkreis Forchheim', 'Landkreis Freising', 'Landkreis Freyung-Grafenau', 'Landkreis Fürstenfeldbruck', 'Landkreis Fürth', 'Landkreis Garmisch-Partenkirchen', 'Landkreis Günzburg', 'Landkreis Haßberge', 'Landkreis Hof', 'Landkreis Kelheim', 'Landkreis Kitzingen', 'Landkreis Kronach', 'Landkreis Kulmbach', 'Landkreis Landsberg am Lech', 'Landkreis Landshut', 'Landkreis Lichtenfels', 'Landkreis Lindau (Bodensee)', 'Landkreis Main-Spessart', 'Landkreis Miesbach', 'Landkreis Miltenberg', 'Landkreis Mühldorf a.Inn', 'Landkreis München', 'Landkreis Neu-Ulm', 'Landkreis Neuburg-Schrobenhausen', 'Landkreis Neumarkt i.d.OPf.', 'Landkreis Neustadt a.d.Aisch-Bad Windsheim', 'Landkreis Neustadt a.d.Waldnaab', 'Landkreis Nürnberger Land', 'Landkreis Oberallgäu', 'Landkreis Ostallgäu', 'Landkreis Passau', 'Landkreis Pfaffenhofen a.d.Ilm', 'Landkreis Regen', 'Landkreis Regensburg', 'Landkreis Rhön-Grabfeld', 'Landkreis Rosenheim', 'Landkreis Roth', 'Landkreis Rottal-Inn', 'Landkreis Schwandorf', 'Landkreis Schweinfurt', 'Landkreis Starnberg', 'Landkreis Straubing-Bogen', 'Landkreis Tirschenreuth', 'Landkreis Traunstein', 'Landkreis Unterallgäu', 'Landkreis Weilheim-Schongau', 'Landkreis Weißenburg-Gunzenhausen', 'Landkreis Wunsiedel i.Fichtelgebirge', 'Landkreis Würzburg', 'Landshut (Stadt)', 'Memmingen (Stadt)', 'München (Stadt)', 'Nürnberg (Stadt)', 'Passau (Stadt)', 'Regensburg (Stadt)', 'Rosenheim (Stadt)', 'Schwabach (Stadt)', 'Schweinfurt (Stadt)', 'Straubing (Stadt)', 'Weiden i.d.OPf. (Stadt)', 'Würzburg (Stadt)'], + 'Berlin' => [], + 'Brandenburg' => ['Brandenburg an der Havel (Stadt)', 'Cottbus (Stadt)', 'Frankfurt (Oder) (Stadt)', 'Landkreis Barnim', 'Landkreis Dahme-Spreewald', 'Landkreis Elbe-Elster', 'Landkreis Havelland', 'Landkreis Märkisch-Oderland', 'Landkreis Oberhavel', 'Landkreis Oberspreewald-Lausitz', 'Landkreis Oder-Spree', 'Landkreis Ostprignitz-Ruppin', 'Landkreis Potsdam-Mittelmark', 'Landkreis Prignitz', 'Landkreis Spree-Neiße', 'Landkreis Teltow-Fläming', 'Landkreis Uckermark', 'Potsdam (Stadt)'], + 'Bremen' => ['Bremen (Stadt)', 'Bremerhaven (Stadt)'], + 'Hamburg' => [], + 'Hessen' => ['Darmstadt (Stadt)', 'Frankfurt am Main (Stadt)', 'Hochtaunuskreis', 'Kassel (Stadt)', 'Lahn-Dill-Kreis', 'Landkreis Bergstraße', 'Landkreis Darmstadt-Dieburg', 'Landkreis Fulda', 'Landkreis Gießen', 'Landkreis Groß-Gerau', 'Landkreis Hersfeld-Rotenburg', 'Landkreis Kassel', 'Landkreis Limburg-Weilburg', 'Landkreis Marburg-Biedenkopf', 'Landkreis Offenbach', 'Landkreis Waldeck-Frankenberg', 'Main-Kinzig-Kreis', 'Main-Taunus-Kreis', 'Odenwaldkreis', 'Offenbach am Main (Stadt)', 'Rheingau-Taunus-Kreis', 'Schwalm-Eder-Kreis', 'Vogelsbergkreis', 'Werra-Meißner-Kreis', 'Wetteraukreis', 'Wiesbaden (Stadt)'], + 'Mecklenburg-Vorpommern' => ['Landkreis Ludwigslust-Parchim', 'Landkreis Mecklenburgische Seenplatte', 'Landkreis Nordwestmecklenburg', 'Landkreis Rostock', 'Landkreis Vorpommern-Greifswald', 'Landkreis Vorpommern-Rügen', 'Rostock (Stadt)', 'Schwerin (Stadt)'], + 'Niedersachsen' => ['Braunschweig (Stadt)', 'Delmenhorst (Stadt)', 'Emden (Stadt)', 'Heidekreis', 'Landkreis Ammerland', 'Landkreis Aurich', 'Landkreis Celle', 'Landkreis Cloppenburg', 'Landkreis Cuxhaven', 'Landkreis Diepholz', 'Landkreis Emsland', 'Landkreis Friesland', 'Landkreis Gifhorn', 'Landkreis Goslar', 'Landkreis Grafschaft Bentheim', 'Landkreis Göttingen', 'Landkreis Hameln-Pyrmont', 'Landkreis Harburg', 'Landkreis Helmstedt', 'Landkreis Hildesheim', 'Landkreis Holzminden', 'Landkreis Leer', 'Landkreis Lüchow-Dannenberg', 'Landkreis Lüneburg', 'Landkreis Nienburg/Weser', 'Landkreis Northeim', 'Landkreis Oldenburg', 'Landkreis Osnabrück', 'Landkreis Osterholz', 'Landkreis Peine', 'Landkreis Rotenburg (Wümme)', 'Landkreis Schaumburg', 'Landkreis Stade', 'Landkreis Uelzen', 'Landkreis Vechta', 'Landkreis Verden', 'Landkreis Wesermarsch', 'Landkreis Wittmund', 'Landkreis Wolfenbüttel', 'Oldenburg (Stadt)', 'Osnabrück (Stadt)', 'Region Hannover', 'Salzgitter (Stadt)', 'Wilhelmshaven (Stadt)', 'Wolfsburg (Stadt)'], + 'Nordrhein-Westfalen' => ['Bielefeld (Stadt)', 'Bochum (Stadt)', 'Bonn (Stadt)', 'Bottrop (Stadt)', 'Dortmund (Stadt)', 'Duisburg (Stadt)', 'Düsseldorf (Stadt)', 'Ennepe-Ruhr-Kreis', 'Essen (Stadt)', 'Gelsenkirchen (Stadt)', 'Hagen (Stadt)', 'Hamm (Stadt)', 'Herne (Stadt)', 'Hochsauerlandkreis', 'Krefeld (Stadt)', 'Köln (Stadt)', 'Landkreis Borken', 'Landkreis Coesfeld', 'Landkreis Düren', 'Landkreis Euskirchen', 'Landkreis Gütersloh', 'Landkreis Heinsberg', 'Landkreis Herford', 'Landkreis Höxter', 'Landkreis Kleve', 'Landkreis Lippe', 'Landkreis Mettmann', 'Landkreis Minden-Lübbecke', 'Landkreis Olpe', 'Landkreis Paderborn', 'Landkreis Recklinghausen', 'Landkreis Siegen-Wittgenstein', 'Landkreis Soest', 'Landkreis Steinfurt', 'Landkreis Städteregion Aachen', 'Landkreis Unna', 'Landkreis Viersen', 'Landkreis Warendorf', 'Landkreis Wesel', 'Leverkusen (Stadt)', 'Märkischer Kreis', 'Mönchengladbach (Stadt)', 'Mülheim an der Ruhr (Stadt)', 'Münster (Stadt)', 'Oberbergischer Kreis', 'Oberhausen (Stadt)', 'Remscheid (Stadt)', 'Rhein-Erft-Kreis', 'Rhein-Kreis Neuss', 'Rhein-Sieg-Kreis', 'Rheinisch-Bergischer Kreis', 'Solingen (Stadt)', 'Wuppertal (Stadt)'], + 'Rheinland-Pfalz' => ['Donnersbergkreis', 'Eifelkreis Bitburg-Prüm', 'Frankenthal (Pfalz) (Stadt)', 'Kaiserslautern (Stadt)', 'Koblenz (Stadt)', 'Landau in der Pfalz (Stadt)', 'Landkreis Ahrweiler', 'Landkreis Altenkirchen (Westerwald)', 'Landkreis Alzey-Worms', 'Landkreis Bad Dürkheim', 'Landkreis Bad Kreuznach', 'Landkreis Bernkastel-Wittlich', 'Landkreis Birkenfeld', 'Landkreis Cochem-Zell', 'Landkreis Germersheim', 'Landkreis Kaiserslautern', 'Landkreis Kusel', 'Landkreis Mainz-Bingen', 'Landkreis Mayen-Koblenz', 'Landkreis Neuwied', 'Landkreis Südliche Weinstraße', 'Landkreis Südwestpfalz', 'Landkreis Trier-Saarburg', 'Landkreis Vulkaneifel', 'Ludwigshafen am Rhein (Stadt)', 'Mainz (Stadt)', 'Neustadt an der Weinstraße (Stadt)', 'Pirmasens (Stadt)', 'Rhein-Hunsrück-Kreis', 'Rhein-Lahn-Kreis', 'Rhein-Pfalz-Kreis', 'Speyer (Stadt)', 'Trier (Stadt)', 'Westerwaldkreis', 'Worms (Stadt)', 'Zweibrücken (Stadt)'], + 'Saarland' => ['Landkreis Merzig-Wadern', 'Landkreis Neunkirchen', 'Landkreis Saarlouis', 'Landkreis St. Wendel', 'Regionalverband Saarbrücken', 'Saarpfalz-Kreis'], + 'Sachsen' => ['Chemnitz (Stadt)', 'Dresden (Stadt)', 'Erzgebirgskreis', 'Landkreis Bautzen', 'Landkreis Görlitz', 'Landkreis Leipzig', 'Landkreis Meißen', 'Landkreis Mittelsachsen', 'Landkreis Nordsachsen', 'Landkreis Sächsische Schweiz-Osterzgebirge', 'Landkreis Zwickau', 'Leipzig (Stadt)', 'Vogtlandkreis'], + 'Sachsen-Anhalt' => ['Altmarkkreis Salzwedel', 'Burgenlandkreis', 'Dessau-Roßlau (Stadt)', 'Halle (Saale) (Stadt)', 'Landkreis Anhalt-Bitterfeld', 'Landkreis Börde', 'Landkreis Harz', 'Landkreis Jerichower Land', 'Landkreis Mansfeld-Südharz', 'Landkreis Stendal', 'Landkreis Wittenberg', 'Magdeburg (Stadt)', 'Saalekreis', 'Salzlandkreis'], + 'Schleswig-Holstein' => ['Flensburg (Stadt)', 'Kiel (Stadt)', 'Landkreis Dithmarschen', 'Landkreis Herzogtum Lauenburg', 'Landkreis Nordfriesland', 'Landkreis Ostholstein', 'Landkreis Pinneberg', 'Landkreis Plön', 'Landkreis Rendsburg-Eckernförde', 'Landkreis Schleswig-Flensburg', 'Landkreis Segeberg', 'Landkreis Steinburg', 'Landkreis Stormarn', 'Lübeck (Stadt)', 'Neumünster (Stadt)'], + 'Thüringen' => ['Erfurt (Stadt)', 'Gera (Stadt)', 'Ilm-Kreis', 'Jena (Stadt)', 'Kyffhäuserkreis', 'Landkreis Altenburger Land', 'Landkreis Eichsfeld', 'Landkreis Gotha', 'Landkreis Greiz', 'Landkreis Hildburghausen', 'Landkreis Nordhausen', 'Landkreis Saalfeld-Rudolstadt', 'Landkreis Schmalkalden-Meiningen', 'Landkreis Sonneberg', 'Landkreis Sömmerda', 'Landkreis Weimarer Land', 'Saale-Holzland-Kreis', 'Saale-Orla-Kreis', 'Suhl (Stadt)', 'Unstrut-Hainich-Kreis', 'Wartburgkreis', 'Weimar (Stadt)'], +]; + +function scope_decode(string $value): ?array +{ + if ($value === 'de') { + return ['bund', null]; + } + if (strpos($value, 'bl:') === 0) { + $land = substr($value, 3); + return isset(SW_REGIONS[$land]) ? ['bundesland', $land] : null; + } + if (strpos($value, 'kr:') === 0) { + $parts = explode(':', substr($value, 3), 2); + if (count($parts) === 2 && isset(SW_REGIONS[$parts[0]]) + && in_array($parts[1], SW_REGIONS[$parts[0]], true)) { + return ['landkreis', $parts[1]]; + } + return null; + } + return null; +} + +function fav_to_gebiet(string $ref): ?string +{ + if ($ref === 'bund') { + return 'de'; + } + $parts = explode(':', $ref, 2); + if (count($parts) !== 2) { + return null; + } + if ($parts[0] === 'bundesland' && isset(SW_REGIONS[$parts[1]])) { + return 'bl:' . $parts[1]; + } + if ($parts[0] === 'landkreis') { + foreach (SW_REGIONS as $land => $kreise) { + if (in_array($parts[1], $kreise, true)) { + return 'kr:' . $land . ':' . $parts[1]; + } + } + } + return null; +} + +function scope_picker(string $name, string $selected, bool $withAll): string +{ + $html = ''; +} + +const SW_CATEGORIES = [ + ['umwelt-klima', 'Umwelt & Klima', 'Environment & Climate'], + ['energie', 'Energie', 'Energy'], + ['wirtschaft', 'Wirtschaft & Mittelstand', 'Economy & Business'], + ['arbeit-soziales', 'Arbeit & Soziales', 'Labour & Social Affairs'], + ['rente', 'Rente & Alterssicherung', 'Pensions'], + ['gesundheit-pflege', 'Gesundheit & Pflege', 'Health & Care'], + ['bildung-forschung', 'Bildung & Forschung', 'Education & Research'], + ['familie-jugend', 'Familie & Jugend', 'Family & Youth'], + ['migration-integration', 'Migration & Integration', 'Migration & Integration'], + ['innere-sicherheit', 'Innere Sicherheit', 'Domestic Security'], + ['justiz-buergerrechte', 'Justiz & Bürgerrechte', 'Justice & Civil Rights'], + ['digitales', 'Digitales & Verwaltung', 'Digital Affairs & Administration'], + ['verkehr', 'Verkehr & Infrastruktur', 'Transport & Infrastructure'], + ['wohnen', 'Wohnen & Mieten', 'Housing & Rents'], + ['landwirtschaft', 'Landwirtschaft & Ernährung', 'Agriculture & Food'], + ['finanzen-steuern', 'Finanzen & Steuern', 'Finance & Taxes'], + ['europa-aussen', 'Europa & Außenpolitik', 'Europe & Foreign Policy'], + ['verteidigung', 'Verteidigung', 'Defence'], + ['kultur-medien-sport', 'Kultur, Medien & Sport', 'Culture, Media & Sports'], + ['verbraucherschutz', 'Verbraucherschutz', 'Consumer Protection'], + ['kommunales', 'Kommunales & Ehrenamt', 'Local Affairs & Volunteering'], + ['demokratie', 'Demokratie & Beteiligung', 'Democracy & Participation'], +]; + +function sw_seed_categories(): void +{ + if ((int) SW::$db->val('SELECT COUNT(*) FROM categories') > 0) { + return; + } + SW::$db->tx(function (): void { + foreach (SW_CATEGORIES as $i => $row) { + SW::$db->run( + 'INSERT INTO categories (slug, name_de, name_en, sort_order) VALUES (?, ?, ?, ?)', + [$row[0], $row[1], $row[2], $i] + ); + } + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, is_system, created_at) VALUES (?, ?, 1, ?)', + ['system', 'de', Clock::nowStr()] + ); + }); +} + +function categories(): array +{ + return SW::$db->all('SELECT * FROM categories ORDER BY sort_order, id'); +} + +function cat_name(array $row): string +{ + return SW::$lang === 'de' ? (string) $row['name_de'] : (string) $row['name_en']; +} + +function topic_has_posted_today(int $userId): bool +{ + return null !== SW::$db->one( + 'SELECT 1 FROM topics WHERE author_id = ? AND created_date = ?', + [$userId, Clock::localDate()] + ); +} + +function topic_create(int $userId, string $title, string $goal, string $reasoning, int $categoryId, string $scopeLevel, ?string $scopeName, string $endMode, ?string $endDate, ?int $endTarget): int +{ + if (topic_has_posted_today($userId)) { + throw new DomainException('flash.topic_daily_limit'); + } + try { + SW::$db->run( + 'INSERT INTO topics (author_id, title, goal, reasoning, category_id, + scope_level, scope_name, end_mode, end_date, end_target, + created_at, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [$userId, $title, $goal, $reasoning, $categoryId, + $scopeLevel, $scopeName, $endMode, $endDate, $endTarget, + Clock::nowStr(), Clock::localDate()] + ); + } catch (PDOException $e) { + throw new DomainException('flash.topic_daily_limit'); + } + return SW::$db->lastId(); +} + +function topic_archive(int $topicId, int $userId): void +{ + $topic = SW::$db->one('SELECT author_id, status FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || (int) $topic['author_id'] !== $userId + || in_array((string) $topic['status'], ['removed', 'archived'], true)) { + throw new DomainException('flash.not_author'); + } + if (topic_has_votes($topicId)) { + throw new DomainException('flash.topic_locked'); + } + SW::$db->run("UPDATE topics SET status = 'archived' WHERE id = ?", [$topicId]); + log_line('INFO', 'topic_archived', ['topic' => $topicId]); +} + +function topic_has_votes(int $topicId): bool +{ + return (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ?', [$topicId]) > 0; +} + +function topic_title_words(string $title): array +{ + $clean = preg_replace('/[^\p{L}\p{N}]+/u', ' ', mb_strtolower($title)); + $words = []; + foreach (preg_split('/\s+/', (string) $clean) as $word) { + if (mb_strlen($word) >= 4) { + $words[$word] = true; + } + } + return array_slice(array_keys($words), 0, 8); +} + +function topics_similar(string $title, ?int $excludeId = null, int $limit = 5): array +{ + $words = topic_title_words($title); + if ($words === []) { + return []; + } + $where = []; + $args = []; + foreach ($words as $word) { + $where[] = 'lower(t.title) LIKE ?'; + $args[] = '%' . $word . '%'; + } + $sql = 'SELECT t.id, t.title FROM topics t WHERE t.status IN (\'active\', \'closed\') AND (' . implode(' OR ', $where) . ')'; + if ($excludeId !== null) { + $sql .= ' AND t.id != ?'; + $args[] = $excludeId; + } + $sql .= ' ORDER BY t.created_at DESC LIMIT 60'; + $scored = []; + foreach (SW::$db->all($sql, $args) as $row) { + $other = topic_title_words((string) $row['title']); + $shared = count(array_intersect($words, $other)); + if ($shared === 0) { + continue; + } + $row['score'] = $shared / max(1, min(count($words), count($other))); + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + return $b['score'] <=> $a['score']; + }); + return array_slice(array_filter($scored, static function (array $r): bool { + return $r['score'] >= 0.5; + }), 0, $limit); +} + +function parse_topic_end(): ?array +{ + $useDate = isset($_POST['end_by_date']); + $useTarget = isset($_POST['end_by_target']); + if (!$useDate && !$useTarget) { + return null; + } + $date = null; + $target = null; + + if ($useDate) { + $raw = post_str('end_date', 10); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) !== 1) { + return null; + } + $max = substr(Clock::addDaysStr(Clock::nowStr(), 365), 0, 10); + if ($raw <= Clock::localDate() || $raw > $max) { + return null; + } + $date = $raw; + } + if ($useTarget) { + $value = post_int('end_value'); + $unit = post_str('end_unit', 10); + if ($value === null || $value < 1 || !in_array($unit, ['count', 'percent'], true)) { + return null; + } + if ($unit === 'percent') { + if ($value > 100) { + return null; + } + $users = (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'); + $value = (int) ceil($users * $value / 100); + } + if ($value > 100000000) { + return null; + } + $target = max(10, $value); + } + $mode = $date !== null && $target !== null ? 'both' : ($date !== null ? 'date' : 'count'); + return [$mode, $date, $target]; +} + +const SW_TOPIC_SELECT = " + SELECT t.*, c.slug AS category_slug, c.name_de, c.name_en, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against + FROM topics t + JOIN categories c ON c.id = t.category_id"; + +function topics_list(array $filters, int $page, int $perPage, ?int $userId): array +{ + $where = ["t.status IN ('active','closed')"]; + $params = []; + if (!empty($filters['category'])) { + $where[] = 'c.slug = :cat'; + $params[':cat'] = $filters['category']; + } + if (!empty($filters['level'])) { + $where[] = 't.scope_level = :level'; + $params[':level'] = $filters['level']; + } + if (!empty($filters['scope'])) { + $where[] = 't.scope_name = :scope'; + $params[':scope'] = $filters['scope']; + } + if (!empty($filters['q'])) { + $where[] = "t.title LIKE :q ESCAPE '\\'"; + $params[':q'] = '%' . addcslashes($filters['q'], '%_\\') . '%'; + } + $whereSql = ' WHERE ' . implode(' AND ', $where); + $total = (int) SW::$db->val( + 'SELECT COUNT(*) FROM topics t JOIN categories c ON c.id = t.category_id' . $whereSql, + $params + ); + + $netExpr = "((SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for')" + . " - (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against'))"; + $sortMode = $filters['sort'] ?? 'net'; + if ($sortMode === 'new') { + $order = ' ORDER BY t.created_at DESC'; + } elseif ($sortMode === 'top') { + $order = ' ORDER BY (votes_for + votes_against) DESC, t.created_at DESC'; + } else { + $order = ' ORDER BY ' . $netExpr . ' DESC, t.created_at DESC'; + } + $cols = "t.*, c.slug AS category_slug, c.name_de, c.name_en, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against"; + $select = 'SELECT ' . $cols . ' FROM topics t JOIN categories c ON c.id = t.category_id'; + $params[':limit'] = $perPage; + $params[':offset'] = max(0, ($page - 1) * $perPage); + $rows = SW::$db->all($select . $whereSql . $order . ' LIMIT :limit OFFSET :offset', $params); + if ($userId !== null) { + $pk = user_pk($userId); + foreach ($rows as $i => $row) { + $tag = vote_tag((int) $row['id'], $pk); + $rows[$i]['my_choice'] = SW::$db->val('SELECT choice FROM votes WHERE topic_id = ? AND voter_tag = ?', [(int) $row['id'], $tag]); + } + } + return ['rows' => $rows, 'total' => $total]; +} + +function topic_find(int $id): ?array +{ + return SW::$db->one(SW_TOPIC_SELECT . ' WHERE t.id = ?', [$id]); +} + +function topic_user_vote(int $topicId, int $userId): ?string +{ + $row = topic_user_vote_row($topicId, $userId); + return $row === null ? null : (string) $row['choice']; +} + +function topic_user_vote_row(int $topicId, int $userId): ?array +{ + $tag = vote_tag($topicId, user_pk($userId)); + $row = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + if ($row === null) { + return null; + } + $row['locked'] = Clock::nowStr() >= Clock::addHoursStr((string) $row['created_at'], SW_VOTE_CHANGE_HOURS); + return $row; +} + +function topics_by_author(int $userId): array +{ + return SW::$db->all(SW_TOPIC_SELECT . ' WHERE t.author_id = ? ORDER BY t.created_at DESC', [$userId]); +} + +function topics_voted_by(int $userId): array +{ + $pk = user_pk($userId); + $out = []; + foreach (SW::$db->all('SELECT id, title, status FROM topics ORDER BY id DESC') as $topic) { + $tag = vote_tag((int) $topic['id'], $pk); + $vote = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [(int) $topic['id'], $tag]); + if ($vote !== null) { + $topic['my_choice'] = (string) $vote['choice']; + $topic['voted_at'] = (string) $vote['created_at']; + $topic['locked'] = Clock::nowStr() >= Clock::addHoursStr((string) $vote['created_at'], SW_VOTE_CHANGE_HOURS); + $out[] = $topic; + } + } + return $out; +} + +function site_stats(): array +{ + return [ + 'topics' => (int) SW::$db->val("SELECT COUNT(*) FROM topics WHERE status = 'active'"), + 'votes' => (int) SW::$db->val('SELECT COUNT(*) FROM votes'), + 'users' => (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'), + ]; +} + +const SW_VOTE_CHANGE_HOURS = 24; + +function vote_tag(int $topicId, string $pkHex): string +{ + return sw_hmac('vote|' . $topicId . '|' . $pkHex); +} + +function user_pk(int $userId): string +{ + return (string) SW::$db->val('SELECT pseudonym_hash FROM users WHERE id = ?', [$userId]); +} + +function topic_close_if_due(array $topic): string +{ + if ($topic['status'] !== 'active') { + return (string) $topic['status']; + } + + $close = false; + if ($topic['end_date'] !== null && Clock::localDate() > (string) $topic['end_date']) { + $close = true; + } + if ($topic['end_target'] !== null) { + $total = (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ?', [(int) $topic['id']]); + if ($total >= (int) $topic['end_target']) { + $close = true; + } + } + if ($close) { + SW::$db->run("UPDATE topics SET status = 'closed' WHERE id = ? AND status = 'active'", [(int) $topic['id']]); + return 'closed'; + } + return 'active'; +} + +function vote_cast(int $userId, int $topicId, string $choice): void +{ + if (!in_array($choice, ['for', 'against', 'none'], true)) { + throw new DomainException('flash.invalid_input'); + } + $topic = SW::$db->one('SELECT * FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || topic_close_if_due($topic) !== 'active') { + throw new DomainException('flash.topic_not_votable'); + } + $tag = vote_tag($topicId, user_pk($userId)); + $existing = SW::$db->one('SELECT choice, created_at FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + if ($existing !== null && Clock::nowStr() >= Clock::addHoursStr((string) $existing['created_at'], SW_VOTE_CHANGE_HOURS)) { + throw new DomainException('flash.vote_locked'); + } + if ($choice === 'none') { + SW::$db->run('DELETE FROM votes WHERE topic_id = ? AND voter_tag = ?', [$topicId, $tag]); + return; + } + $now = Clock::nowStr(); + SW::$db->run( + 'INSERT INTO votes (topic_id, voter_tag, choice, created_at, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(topic_id, voter_tag) DO UPDATE SET choice = excluded.choice, updated_at = excluded.updated_at', + [$topicId, $tag, $choice, $now, $now] + ); + if ($existing === null) { + topic_close_if_due(array_merge($topic, ['status' => 'active'])); + } +} + +function fav_valid(string $kind, string $ref): bool +{ + if ($kind === 'topic') { + return preg_match('/^\d{1,10}$/', $ref) === 1 + && null !== SW::$db->one('SELECT 1 FROM topics WHERE id = ?', [(int) $ref]); + } + if ($kind === 'category') { + return null !== SW::$db->one('SELECT 1 FROM categories WHERE slug = ?', [$ref]); + } + if ($kind === 'scope') { + if ($ref === 'bund') { + return true; + } + $parts = explode(':', $ref, 2); + if (count($parts) !== 2) { + return false; + } + if ($parts[0] === 'bundesland') { + return isset(SW_REGIONS[$parts[1]]); + } + if ($parts[0] === 'landkreis') { + foreach (SW_REGIONS as $kreise) { + if (in_array($parts[1], $kreise, true)) { + return true; + } + } + } + return false; + } + return false; +} + +function fav_toggle(int $userId, string $kind, string $ref): bool +{ + if (!fav_valid($kind, $ref)) { + throw new DomainException('flash.invalid_input'); + } + $exists = SW::$db->one('SELECT 1 FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); + if ($exists !== null) { + SW::$db->run('DELETE FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); + return false; + } + SW::$db->run( + 'INSERT INTO favorites (user_id, kind, ref, created_at) VALUES (?, ?, ?, ?)', + [$userId, $kind, $ref, Clock::nowStr()] + ); + return true; +} + +function fav_is(int $userId, string $kind, string $ref): bool +{ + return null !== SW::$db->one('SELECT 1 FROM favorites WHERE user_id = ? AND kind = ? AND ref = ?', [$userId, $kind, $ref]); +} + +function fav_list(int $userId): array +{ + return SW::$db->all( + 'SELECT f.kind, f.ref, c.name_de, c.name_en, t.title AS topic_title, t.status AS topic_status + FROM favorites f + LEFT JOIN categories c ON f.kind = \'category\' AND c.slug = f.ref + LEFT JOIN topics t ON f.kind = \'topic\' AND t.id = CAST(f.ref AS INTEGER) + WHERE f.user_id = ? + ORDER BY f.kind, f.ref', + [$userId] + ); +} + +const SW_LAWS = [ + 'stgb-130-1' => [ + 'norm' => '§ 130 Abs. 1 StGB', 'titel' => 'Volksverhetzung', + 'schlagworte' => 'volksverhetzung hass hetze aufstacheln menschenwürde gruppe bevölkerung', + 'text' => 'Wer in einer Weise, die geeignet ist, den öffentlichen Frieden zu stören, 1. gegen eine nationale, rassische, religiöse oder durch ihre ethnische Herkunft bestimmte Gruppe, gegen Teile der Bevölkerung oder gegen einen Einzelnen wegen dessen Zugehörigkeit zu einer vorbezeichneten Gruppe oder zu einem Teil der Bevölkerung zum Hass aufstachelt, zu Gewalt- oder Willkürmaßnahmen auffordert oder 2. die Menschenwürde anderer dadurch angreift, dass er eine vorbezeichnete Gruppe, Teile der Bevölkerung oder einen Einzelnen wegen dessen Zugehörigkeit zu einer vorbezeichneten Gruppe oder zu einem Teil der Bevölkerung beschimpft, böswillig verächtlich macht oder verleumdet, wird mit Freiheitsstrafe von drei Monaten bis zu fünf Jahren bestraft.', + ], + 'stgb-86a-1' => [ + 'norm' => '§ 86a Abs. 1 StGB', 'titel' => 'Verwenden von Kennzeichen verfassungswidriger und terroristischer Organisationen', + 'schlagworte' => 'kennzeichen symbole verfassungswidrig hakenkreuz parole organisation', + 'text' => 'Mit Freiheitsstrafe bis zu drei Jahren oder mit Geldstrafe wird bestraft, wer 1. im Inland Kennzeichen einer der in § 86 Abs. 1 Nr. 1, 2 und 4 oder Absatz 2 bezeichneten Parteien oder Vereinigungen verbreitet oder öffentlich, in einer Versammlung oder in einem von ihm verbreiteten Inhalt (§ 11 Absatz 3) verwendet oder 2. einen Inhalt (§ 11 Absatz 3), der ein derartiges Kennzeichen darstellt oder enthält, zur Verbreitung oder Verwendung im Inland oder Ausland in der in Nummer 1 bezeichneten Art und Weise herstellt, vorrätig hält, einführt oder ausführt.', + ], + 'stgb-111-1' => [ + 'norm' => '§ 111 Abs. 1 StGB', 'titel' => 'Öffentliche Aufforderung zu Straftaten', + 'schlagworte' => 'aufforderung straftat aufruf gewalt anstiftung', + 'text' => 'Wer öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) zu einer rechtswidrigen Tat auffordert, wird wie ein Anstifter (§ 26) bestraft.', + ], + 'stgb-185' => [ + 'norm' => '§ 185 StGB', 'titel' => 'Beleidigung', + 'schlagworte' => 'beleidigung ehre schmähung', + 'text' => 'Die Beleidigung wird mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe und, wenn die Beleidigung öffentlich, in einer Versammlung, durch Verbreiten eines Inhalts (§ 11 Absatz 3) oder mittels einer Tätlichkeit begangen wird, mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-186' => [ + 'norm' => '§ 186 StGB', 'titel' => 'Üble Nachrede', + 'schlagworte' => 'üble nachrede tatsache herabwürdigen rufschädigung', + 'text' => 'Wer in Beziehung auf einen anderen eine Tatsache behauptet oder verbreitet, welche denselben verächtlich zu machen oder in der öffentlichen Meinung herabzuwürdigen geeignet ist, wird, wenn nicht diese Tatsache erweislich wahr ist, mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe und, wenn die Tat öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) begangen ist, mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-187' => [ + 'norm' => '§ 187 StGB', 'titel' => 'Verleumdung', + 'schlagworte' => 'verleumdung unwahre tatsache lüge kredit', + 'text' => 'Wer wider besseres Wissen in Beziehung auf einen anderen eine unwahre Tatsache behauptet oder verbreitet, welche denselben verächtlich zu machen oder in der öffentlichen Meinung herabzuwürdigen oder dessen Kredit zu gefährden geeignet ist, wird mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe und, wenn die Tat öffentlich, in einer Versammlung oder durch Verbreiten eines Inhalts (§ 11 Absatz 3) begangen ist, mit Freiheitsstrafe bis zu fünf Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-240-1' => [ + 'norm' => '§ 240 Abs. 1 StGB', 'titel' => 'Nötigung', + 'schlagworte' => 'nötigung zwang drohung übel', + 'text' => 'Wer einen Menschen rechtswidrig mit Gewalt oder durch Drohung mit einem empfindlichen Übel zu einer Handlung, Duldung oder Unterlassung nötigt, wird mit Freiheitsstrafe bis zu drei Jahren oder mit Geldstrafe bestraft.', + ], + 'stgb-241-1' => [ + 'norm' => '§ 241 Abs. 1 StGB', 'titel' => 'Bedrohung', + 'schlagworte' => 'bedrohung drohung verbrechen gewalt', + 'text' => 'Wer einen Menschen mit der Begehung einer gegen ihn oder eine ihm nahestehende Person gerichteten rechtswidrigen Tat gegen die sexuelle Selbstbestimmung, die körperliche Unversehrtheit, die persönliche Freiheit oder gegen eine Sache von bedeutendem Wert bedroht, wird mit Freiheitsstrafe bis zu einem Jahr oder mit Geldstrafe bestraft.', + ], + 'stgb-126a-1' => [ + 'norm' => '§ 126a Abs. 1 StGB', 'titel' => 'Gefährdendes Verbreiten personenbezogener Daten', + 'schlagworte' => 'doxxing personenbezogene daten adresse veröffentlichen gefährdung', + 'text' => 'Wer personenbezogene Daten einer anderen Person in einer Art und Weise, die geeignet ist, diese Person oder eine ihr nahestehende Person der Gefahr 1. eines gegen sie gerichteten Verbrechens oder 2. einer sonstigen gegen sie gerichteten rechtswidrigen Tat gegen die körperliche Unversehrtheit, die persönliche Freiheit oder gegen eine Sache von bedeutendem Wert auszusetzen, öffentlich zugänglich macht, wird mit Freiheitsstrafe bis zu zwei Jahren oder mit Geldstrafe bestraft.', + ], +]; + +function law_search(string $q): array +{ + $q = mb_strtolower(trim($q)); + if ($q === '') { + return []; + } + $hits = []; + foreach (SW_LAWS as $id => $law) { + $haystack = mb_strtolower($law['norm'] . ' ' . $law['titel'] . ' ' . $law['schlagworte'] . ' ' . $law['text']); + if (mb_strpos($haystack, $q) !== false || mb_strpos(str_replace(['§', ' '], '', $haystack), str_replace(['§', ' '], '', $q)) !== false) { + $hits[$id] = $law; + } + } + return $hits; +} + +function report_open_for(int $topicId): ?array +{ + return SW::$db->one( + "SELECT * FROM reports WHERE topic_id = ? AND status IN ('pending','voting')", + [$topicId] + ); +} + +function reports_by(int $userId): array +{ + return SW::$db->all( + 'SELECT r.id, r.status, r.created_at, r.decided_at, t.id AS topic_id, t.title + FROM reports r JOIN topics t ON t.id = r.topic_id + WHERE r.reporter_id = ? + ORDER BY r.created_at DESC', + [$userId] + ); +} + +function reports_today_by(int $reporterId): int +{ + $dayEnd = Clock::nextLocalMidnightUtcStr(); + $dayStart = Clock::addDaysStr($dayEnd, -1); + return (int) SW::$db->val( + 'SELECT COUNT(*) FROM reports WHERE reporter_id = ? AND created_at >= ? AND created_at < ?', + [$reporterId, $dayStart, $dayEnd] + ); +} + +function jury_draw(int $reporterId, int $authorId, int $totalUsers): array +{ + $eligible = SW::$db->all( + "SELECT u.id FROM users u + WHERE u.is_system = 0 + AND u.id NOT IN (?, ?) + AND (u.jury_cooldown_until IS NULL OR u.jury_cooldown_until <= ?) + AND u.id NOT IN ( + SELECT rj.user_id FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + WHERE r.status IN ('pending','voting') + )", + [$reporterId, $authorId, Clock::nowStr()] + ); + $ids = array_map(static function (array $row): int { + return (int) $row['id']; + }, $eligible); + $target = max((int) SW::$cfg['jury_min'], (int) ceil($totalUsers * (float) SW::$cfg['jury_share'])); + $count = count($ids); + if ($count <= $target) { + return $ids; + } + for ($i = $count - 1; $i > 0; $i--) { + $j = random_int(0, $i); + $tmp = $ids[$i]; + $ids[$i] = $ids[$j]; + $ids[$j] = $tmp; + } + return array_slice($ids, 0, $target); +} + +function report_create(int $topicId, int $reporterId, string $lawId): int +{ + if (!isset(SW_LAWS[$lawId])) { + throw new DomainException('flash.report_no_law'); + } + $topic = SW::$db->one('SELECT id, author_id, status FROM topics WHERE id = ?', [$topicId]); + if ($topic === null || $topic['status'] !== 'active') { + throw new DomainException('flash.topic_not_reportable'); + } + return SW::$db->tx(function () use ($topicId, $reporterId, $lawId, $topic): int { + if (report_open_for($topicId) !== null) { + throw new DomainException('flash.report_already_open'); + } + $mine = SW::$db->one('SELECT 1 FROM reports WHERE topic_id = ? AND reporter_id = ?', [$topicId, $reporterId]); + if ($mine !== null) { + throw new DomainException('flash.report_duplicate'); + } + if (reports_today_by($reporterId) >= (int) SW::$cfg['reports_per_day']) { + throw new DomainException('flash.report_daily_limit'); + } + $totalUsers = (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0'); + $jurors = jury_draw($reporterId, (int) $topic['author_id'], $totalUsers); + if ($jurors === []) { + throw new DomainException('flash.report_too_few_users'); + } + $quorum = min( + count($jurors), + max((int) SW::$cfg['quorum_min'], (int) ceil($totalUsers * (float) SW::$cfg['quorum_share'])) + ); + SW::$db->run( + 'INSERT INTO reports (topic_id, reporter_id, criteria, freetext, status, + jury_size, quorum, created_at, voting_starts_at) + VALUES (?, ?, ?, ?, \'pending\', ?, ?, ?, ?)', + [$topicId, $reporterId, $lawId, null, + count($jurors), $quorum, Clock::nowStr(), Clock::nextLocalMidnightUtcStr()] + ); + $reportId = SW::$db->lastId(); + foreach ($jurors as $jurorId) { + SW::$db->run('INSERT INTO report_jurors (report_id, user_id) VALUES (?, ?)', [$reportId, $jurorId]); + } + return $reportId; + }); +} + +function jury_pending_for(int $userId): ?array +{ + return SW::$db->one( + "SELECT r.*, t.title, t.goal, t.reasoning + FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + JOIN topics t ON t.id = r.topic_id + WHERE rj.user_id = ? AND rj.vote IS NULL AND r.status = 'voting' + ORDER BY r.voting_starts_at + LIMIT 1", + [$userId] + ); +} + +function jury_upcoming_for(int $userId): ?array +{ + return SW::$db->one( + "SELECT r.id, r.voting_starts_at + FROM report_jurors rj JOIN reports r ON r.id = rj.report_id + WHERE rj.user_id = ? AND rj.vote IS NULL AND r.status = 'pending' + ORDER BY r.voting_starts_at LIMIT 1", + [$userId] + ); +} + +function jury_tally(int $reportId): array +{ + $row = SW::$db->one( + "SELECT COUNT(*) AS seats, COUNT(vote) AS cast, + SUM(CASE WHEN vote = 'confirm' THEN 1 ELSE 0 END) AS confirm, + SUM(CASE WHEN vote = 'reject' THEN 1 ELSE 0 END) AS reject, + SUM(CASE WHEN vote = 'neutral' THEN 1 ELSE 0 END) AS neutral + FROM report_jurors WHERE report_id = ?", + [$reportId] + ); + return [ + 'seats' => (int) ($row['seats'] ?? 0), + 'cast' => (int) ($row['cast'] ?? 0), + 'confirm' => (int) ($row['confirm'] ?? 0), + 'reject' => (int) ($row['reject'] ?? 0), + 'neutral' => (int) ($row['neutral'] ?? 0), + ]; +} + +function jury_deadline(array $report): string +{ + return Clock::addHoursStr((string) $report['voting_starts_at'], (int) SW::$cfg['report_vote_hours']); +} + +function jury_decide_if_due(array $report): void +{ + if (Clock::nowStr() < jury_deadline($report)) { + return; + } + $tally = jury_tally((int) $report['id']); + $quorumEffective = min((int) $report['quorum'], $tally['seats']); + if ($tally['cast'] < $quorumEffective) { + return; + } + $removed = $tally['confirm'] > $tally['reject']; + $now = Clock::nowStr(); + SW::$db->run( + 'UPDATE reports SET status = ?, decided_at = ? WHERE id = ?', + [$removed ? 'decided_removed' : 'decided_kept', $now, (int) $report['id']] + ); + if ($removed) { + SW::$db->run("UPDATE topics SET status = 'removed' WHERE id = ?", [(int) $report['topic_id']]); + } + $cooldownUntil = Clock::addDaysStr($now, (int) SW::$cfg['jury_cooldown_days']); + SW::$db->run( + 'UPDATE users SET jury_cooldown_until = ? + WHERE id IN (SELECT user_id FROM report_jurors WHERE report_id = ?)', + [$cooldownUntil, (int) $report['id']] + ); +} + +function jury_cast(int $reportId, int $userId, string $vote): void +{ + if (!in_array($vote, ['confirm', 'reject', 'neutral'], true)) { + throw new DomainException('flash.invalid_input'); + } + SW::$db->tx(function () use ($reportId, $userId, $vote): void { + $report = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$reportId]); + if ($report === null || $report['status'] !== 'voting') { + throw new DomainException('flash.jury_not_open'); + } + $seat = SW::$db->one('SELECT vote FROM report_jurors WHERE report_id = ? AND user_id = ?', [$reportId, $userId]); + if ($seat === null) { + throw new DomainException('flash.jury_not_member'); + } + if ($seat['vote'] !== null) { + throw new DomainException('flash.jury_already_voted'); + } + SW::$db->run( + 'UPDATE report_jurors SET vote = ?, voted_at = ? WHERE report_id = ? AND user_id = ?', + [$vote, Clock::nowStr(), $reportId, $userId] + ); + jury_decide_if_due($report); + }); +} + +function maintenance_tick(): void +{ + SW::$db->run( + "UPDATE topics SET status = 'closed' + WHERE status = 'active' AND end_date IS NOT NULL AND end_date < ?", + [Clock::localDate()] + ); + SW::$db->run( + "UPDATE reports SET status = 'voting' WHERE status = 'pending' AND voting_starts_at <= ?", + [Clock::nowStr()] + ); + foreach (SW::$db->all("SELECT * FROM reports WHERE status = 'voting'") as $report) { + SW::$db->tx(function () use ($report): void { + jury_decide_if_due($report); + }); + } + rate_gc(); +} + +function maintenance_tick_throttled(): void +{ + $now = Clock::now()->getTimestamp(); + $last = (int) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'last_tick'") ?? 0); + if (($now - $last) < 30) { + return; + } + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('last_tick', ?) + ON CONFLICT(k) DO UPDATE SET v = excluded.v", + [(string) $now] + ); + maintenance_tick(); +} + +function account_delete(int $userId): void +{ + SW::$db->tx(function () use ($userId): void { + $systemId = (int) SW::$db->val('SELECT id FROM users WHERE is_system = 1 LIMIT 1'); + SW::$db->run('UPDATE topics SET author_id = ? WHERE author_id = ?', [$systemId, $userId]); + SW::$db->run('DELETE FROM users WHERE id = ?', [$userId]); + }); +} + +const SW_DE = [ + 'app.tagline' => 'Digitale Bürgerbeteiligung', + 'banner.test' => 'Testbetrieb – keine offizielle Seite der Bundesregierung oder einer Behörde.', + 'a11y.skip' => 'Zum Inhalt springen', + 'nav.topics' => 'Themen', + 'nav.jury' => 'Jury', + 'auth.login' => 'Ausweis auflegen', + 'auth.logout' => 'Abmelden', + 'common.date_format' => 'd.m.Y', + 'common.datetime_format' => 'd.m.Y, H:i', + 'common.back_home' => 'Zur Startseite', + + 'topics.filter_category' => 'Kategorie', + 'topics.filter_all' => 'Alle', + 'topics.search' => 'Suche', + 'topics.sort_net' => 'Größte Zustimmung', + 'topics.sort' => 'Sortierung', + 'topics.sort_new' => 'Neueste', + 'topics.sort_top' => 'Meiste Stimmen', + 'topics.apply' => 'Filtern', + 'topics.none' => 'Keine Themen gefunden.', + 'topics.prev' => 'Zurück', + 'topics.next' => 'Weiter', + 'topics.page_of' => 'Seite {p} von {n}', + + 'scope.bund' => 'Deutschland', + 'scope.bundesland' => 'Bundesland', + 'scope.landkreis' => 'Landkreis', + + 'topic.goal_label' => 'Ziel', + 'topic.reasoning_label' => 'Begründung', + 'topic.report_link' => 'Inhalt melden', + 'topic.report_open' => 'Gemeinschaftsprüfung läuft.', + 'topic.remember' => 'Merken', + 'topic.this' => 'Dieses Thema', + 'topic.removed_title' => 'Inhalt entfernt', + 'topic.removed_text' => 'Dieser Beitrag wurde nach Prüfung durch eine ausgeloste Bürger-Jury entfernt.', + 'topic.your_vote' => 'Ihre Stimme: {choice}', + + 'vote.for' => 'Dafür', + 'vote.against' => 'Dagegen', + 'vote.withdraw' => 'Stimme zurückziehen', + 'vote.login_hint' => 'Zum Abstimmen Ausweis auflegen', + 'vote.bar_aria' => 'Abstimmungsergebnis', + + 'topic.new_title' => 'Thema einbringen', + 'topic.posted_today' => 'Heute bereits ein Thema eingebracht. Das nächste ist ab 00:00 Uhr möglich.', + 'topic.next_in' => 'Nächstes Thema in', + 'common.close' => 'Schließen', + 'topics.clear' => 'Filter zurücksetzen', + 'scope.whole' => 'gesamt', + 'topic.f_end' => 'Ende der Abstimmung', + 'topic.end_by_date' => 'an einem Datum', + 'topic.end_by_target' => 'bei erreichter Stimmenzahl', + 'topic.end_value_ph' => 'Anzahl', + 'topic.end_unit' => 'Einheit', + 'topic.end_unit_count' => 'X Stimmen', + 'topic.end_unit_percent' => '% Stimmen', + 'topic.err_end' => 'Bitte ein gültiges Ende angeben (Datum in der Zukunft oder Zielzahl).', + 'topic.ends_on' => 'Läuft bis {date}', + 'topic.ends_count' => '{have} von {target} Stimmen', + 'topic.ends_both' => 'Läuft bis {date} oder {have} von {target} Stimmen', + 'topic.ended' => 'beendet', + 'topic.archive' => 'Thema archivieren', + 'topic.archive_confirm' => 'Das Thema verschwindet aus den Listen und ist nicht mehr wählbar. Gelöscht wird es nicht.', + 'topic.archived_badge' => 'Archiviert', + 'topic.archived_note' => 'Dieses Thema wurde vom Verfasser archiviert.', + 'home.recent_votes' => 'Kürzlich abgestimmt (noch änderbar)', + 'vote.changeable_until' => 'änderbar bis {date}', + 'topic.f_title' => 'Titel', + 'topic.f_goal' => 'Ziel', + 'topic.f_reasoning' => 'Begründung', + 'topic.f_category' => 'Kategorie', + 'topic.f_scope' => 'Geltungsbereich', + 'topic.f_choose' => 'Bitte wählen', + 'topic.submit' => 'Veröffentlichen', + 'topic.err_title' => 'Titel: mindestens 8 Zeichen.', + 'topic.err_goal' => 'Ziel: mindestens 10 Zeichen.', + 'topic.err_reasoning' => 'Begründung: mindestens 10 Zeichen.', + 'topic.err_category' => 'Bitte eine Kategorie wählen.', + 'topic.err_scope' => 'Bitte einen Geltungsbereich wählen.', + + 'auth.with' => 'Mit {app} anmelden', + 'testmode.chip' => 'Testmodus', + 'flash.testmode_ended' => 'Echtbetrieb eingerichtet, Testdaten gelöscht.', + 'flash.testmode_confirm' => 'Bitte das Beenden bestätigen.', + 'setup.title' => 'Echtbetrieb einrichten', + 'setup.checks' => 'Voraussetzungen', + 'setup.check_data' => 'Datenverzeichnis beschreibbar', + 'setup.check_https' => 'HTTPS aktiv', + 'setup.check_sodium' => 'Kryptographie (sodium) verfügbar', + 'setup.check_htaccess' => 'Zugriffsschutz (.htaccess) vorhanden', + 'setup.check_keys' => 'Freigegebene Ausweis-Schlüssel: {n}', + 'setup.path' => 'Zugang', + 'setup.path_eid' => 'Eigener eID-Server', + 'setup.path_list' => 'Eigene Trust-Liste', + 'setup.f_server' => 'eID-Server (SOAP-Adresse)', + 'setup.f_cert' => 'Client-Zertifikat (Pfad)', + 'setup.f_key' => 'Privater Schlüssel (Pfad)', + 'setup.f_client' => 'Ausweis-App auf dem Gerät', + 'setup.f_sync' => 'Abgleich-Adresse der Freigabeliste', + 'setup.f_nect' => 'Nect Wallet: Startadresse', + 'setup.token' => 'Einrichtungsschlüssel', + 'setup.token_hint' => 'data/setup.token', + 'setup.check_btn' => 'Verbindung prüfen', + 'setup.finish_btn' => 'Umschalten', + 'setup.confirm' => 'Testdaten löschen', + 'setup.end_locked' => 'Umschalten nur durch Bearbeiten der Datei.', + 'setup.err_client' => 'Adresse der Ausweis-App ist ungültig.', + 'setup.err_https' => 'Adressen müssen mit https:// beginnen.', + 'setup.err_file' => 'Zertifikat oder Schlüssel ist nicht lesbar.', + 'setup.err_server_missing' => 'Für den eID-Server wird dessen SOAP-Adresse benötigt.', + 'setup.err_list_empty' => 'Die Freigabeliste ist leer: Abgleich-Adresse angeben oder zuerst Ausweise ausgeben.', + 'setup.err_token' => 'Einrichtungsschlüssel stimmt nicht.', + 'setup.err_check_first' => 'Bitte zuerst die Verbindung prüfen.', + 'setup.err_save' => 'Einstellungen nicht speicherbar – Rechte des Ordners data/ prüfen.', + 'flash.setup_ok' => 'eID-Server antwortet.', + 'flash.setup_failed' => 'eID-Server antwortet nicht oder liefert keine Sitzung.', + 'flash.setup_no_server' => 'Keine eID-Server-Adresse angegeben.', + 'flash.setup_list_ok' => 'Trust-Liste erreichbar: {n} Schlüssel.', + 'flash.setup_list_failed' => 'Trust-Liste nicht erreichbar.', + 'auth.title' => 'Mit Ausweis anmelden', + 'auth.tap' => 'Ausweis auflegen', + 'auth.test_login' => 'Test-Anmeldung starten', + 'auth.hold' => 'Ausweis an das Gerät halten …', + + 'me.jury_upcoming' => 'Ausgelost; Abstimmung ab {date}, 00:00 Uhr.', + + 'jury.title' => 'Bürger-Jury', + 'jury.intro' => 'Per Los ausgewählt. Bitte anhand der Kriterien bewerten; Enthaltung zulässig.', + 'jury.blocked' => 'Bis zur Stimmabgabe sind die übrigen Funktionen gesperrt.', + 'jury.none' => 'Keine Jury-Aufgabe.', + 'jury.upcoming' => 'Ausgelost; Abstimmung ab {date}, 00:00 Uhr. Bis dahin ist nichts zu tun.', + 'jury.reported' => 'Gemeldeter Inhalt', + 'jury.law' => 'Gemeldeter Gesetzesverstoß', + 'jury.question' => 'Verstößt der Inhalt gegen das zitierte Gesetz?', + 'jury.confirm' => 'Ja – entfernen', + 'jury.reject' => 'Nein – behalten', + 'jury.neutral' => 'Enthaltung', + 'jury.stats' => '{cast} von {seats} Stimmen abgegeben · Quorum: {quorum}', + 'jury.deadline' => 'Reguläres Ende: {date}; danach wird bei erreichtem Quorum entschieden.', + + 'report.title' => 'Inhalt melden', + 'report.intro' => 'Einziger Meldegrund: Verstoß gegen ein Gesetz. Der verletzte Paragraph wird 1:1 zitiert.', + 'report.process' => 'Es entscheidet eine ausgeloste Bürger-Jury (1 %; ab 00:00 Uhr, 24 h, Quorum 0,5 %).', + 'report.search' => 'Gesetz finden (Schlagwort oder Paragraph)', + 'report.pick' => 'Verletztes Gesetz (wird 1:1 zitiert)', + 'report.none_found' => 'Kein Treffer. Anderes Schlagwort oder Paragraphennummer versuchen.', + 'report.submit' => 'Meldung abschicken', + 'report.cancel' => 'Abbrechen', + + 'flash.session_expired' => 'Sitzung beendet. Bitte Ausweis erneut auflegen.', + 'flash.auth_expired' => 'Anmeldung abgelaufen – bitte Ausweis erneut auflegen.', + 'flash.login_required' => 'Bitte zuerst den Ausweis auflegen.', + 'flash.card_required' => 'Bestätigung fehlgeschlagen. Bitte Ausweis erneut auflegen.', + 'flash.rate_limited' => 'Zu viele Anfragen. Bitte kurz warten.', + 'flash.csrf' => 'Anfrage konnte nicht zugeordnet werden. Bitte erneut versuchen.', + 'flash.invalid_input' => 'Ungültige Eingabe.', + 'flash.topic_daily_limit' => 'Heute bereits ein Thema eingebracht; das nächste ab 00:00 Uhr.', + 'flash.topic_created' => 'Thema veröffentlicht.', + 'flash.topic_not_votable' => 'Abstimmung nicht möglich.', + 'flash.topic_not_reportable' => 'Meldung nicht möglich.', + 'flash.vote_saved' => 'Stimme gespeichert.', + 'flash.vote_withdrawn' => 'Stimme zurückgezogen.', + 'flash.favorite_added' => 'Favorit hinzugefügt.', + 'flash.favorite_removed' => 'Favorit entfernt.', + 'flash.report_already_open' => 'Für dieses Thema läuft bereits eine Prüfung.', + 'flash.report_duplicate' => 'Dieses Thema wurde von Ihnen bereits gemeldet.', + 'flash.report_daily_limit' => 'Tageslimit für Meldungen erreicht.', + 'flash.report_too_few_users' => 'Für eine Jury sind derzeit zu wenige Teilnehmende registriert.', + 'flash.report_no_law' => 'Bitte das verletzte Gesetz auswählen.', + 'flash.not_author' => 'Nur der Verfasser kann dieses Thema ändern.', + 'flash.topic_locked' => 'Sobald abgestimmt wurde, bleibt das Thema dauerhaft bestehen.', + 'topic.similar' => 'Ähnliche Themen', + 'topic.similar_hint' => 'Zu diesem Titel gibt es bereits ähnliche Themen. Einbringen ist trotzdem möglich.', + 'flash.topic_archived' => 'Thema archiviert.', + 'flash.vote_locked' => 'Diese Stimme ist nach 24 Stunden nicht mehr änderbar.', + 'flash.report_created' => 'Meldung aufgenommen. Die Jury ist ausgelost; Abstimmung ab 00:00 Uhr.', + 'flash.jury_not_open' => 'Diese Abstimmung ist nicht (mehr) offen.', + 'flash.jury_not_member' => 'Keine Berechtigung für diese Jury.', + 'flash.jury_already_voted' => 'In dieser Prüfung wurde bereits abgestimmt.', + 'flash.jury_voted' => 'Jury-Stimme gezählt.', + 'flash.auth_failed' => 'Anmeldung fehlgeschlagen.', + 'flash.eid_required' => 'Anmeldung nur mit echtem Ausweis über eine Ausweis-App.', + 'flash.eid_provider_off' => 'Dieser Anbieter ist in dieser Installation noch nicht eingerichtet.', + 'flash.no_card' => 'Kein Ausweis vorhanden. Bitte Ausweis bereitstellen.', + 'flash.card_not_authorized' => 'Dieser Ausweis ist nicht autorisiert.', + 'flash.card_ready' => 'Ausweis bereit. Zum Anmelden auflegen.', + 'flash.card_new' => 'Bereit für einen anderen Ausweis. Zum Anmelden auflegen.', + 'flash.logged_out' => 'Abgemeldet.', + + 'error.not_found_title' => 'Seite nicht gefunden', + 'error.not_found' => 'Die angeforderte Seite existiert nicht oder wurde entfernt.', + 'error.generic_title' => 'Fehler', + 'error.generic' => 'Es ist ein Fehler aufgetreten. Bitte später erneut versuchen.', + 'error.method' => 'Anfrageart nicht unterstützt.', + + 'footer.imprint' => 'Impressum', + 'footer.privacy' => 'Datenschutz', + + 'imprint.h' => 'Impressum', + 'imprint.p1' => "Florian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + 'imprint.p2' => 'E-Mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Verantwortlich für den Inhalt:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + + 'privacy.h' => 'Datenschutzerklärung', + 'privacy.p1' => 'Diese Anwendung verarbeitet keine Klaridentitäten wie Name, Anschrift, Geburtsdatum oder E-Mail-Adresse.', + 'privacy.p2' => 'Zur Nutzung der Seite wird ein technisches Profil erstellt. Dieses Profil sowie die auf der Seite abgegebenen Eingaben, Stimmen, Themen, Meldungen und Entscheidungen werden dauerhaft gespeichert. Die dauerhafte Speicherung dient der Eindeutigkeit, der Nachvollziehbarkeit, der Verhinderung mehrfacher oder nachträglich manipulierter Vorgänge und der Sicherheit der Anwendung.', + 'privacy.p3' => 'Zur Wiedererkennung verwendet die Anwendung pseudonyme technische Kennungen, zum Beispiel Hash- oder HMAC-Werte mit serverseitigem Geheimnis. Diese Kennungen dienen der Anmeldung, Sitzungsführung, Missbrauchsabwehr und Verhinderung von Doppelstimmen.', + 'privacy.p4' => 'Die Seite verwendet ein technisch notwendiges Sitzungs-Cookie für Anmeldung und Sicherheit. Weitere Cookies, Tracking, Werbung und externe Drittinhalte werden nicht eingesetzt.', + 'privacy.p5' => 'Zur Missbrauchsabwehr und Fehleranalyse können technische Protokolldaten verarbeitet werden. Diese werden nur für Sicherheit und Betrieb verwendet.', + 'privacy.p6' => 'Rechtsgrundlage ist, soweit erforderlich, Art. 6 Abs. 1 lit. f DSGVO. Das berechtigte Interesse liegt im sicheren Betrieb der Anwendung, der Eindeutigkeit der Abstimmungen und dem Schutz vor Manipulation.', + 'privacy.p7' => 'Betroffene Personen haben nach Maßgabe der DSGVO Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Widerspruch und Beschwerde bei einer Datenschutzaufsichtsbehörde.', + 'privacy.p8' => 'Löschung und Einschränkung der Verarbeitung können abgelehnt oder nur eingeschränkt umgesetzt werden, soweit die weitere Speicherung zur Integrität, Nachvollziehbarkeit, Missbrauchsabwehr, Verhinderung von Doppelstimmen oder Verhinderung nachträglicher Manipulation erforderlich ist.', + 'privacy.p9' => 'Nutzer sind für die von ihnen eingestellten Inhalte selbst verantwortlich. Der Betreiber macht sich diese Inhalte nicht zu eigen. Rechtswidrige Inhalte können nach Kenntnisnahme geprüft, eingeschränkt oder entfernt werden.', + 'privacy.p10' => "Verantwortlich:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]\nE-Mail: datenschutz@buergerabstimmung.org", + 'consent.text' => 'Diese Seite verwendet ein technisch notwendiges Sitzungs-Cookie für Anmeldung und Sicherheit. Details entnehmen Sie der Datenschutzerklärung.', + 'consent.accept' => 'Akzeptieren', + 'consent.privacy' => 'Datenschutzerklärung', + 'error.consent' => 'Dafür wird das Sitzungs-Cookie benötigt. Bitte unten zustimmen.', +]; + +const SW_EN = [ + 'app.tagline' => 'Digital citizen participation', + 'banner.test' => 'Test operation – not an official website of the German federal government or any public authority.', + 'a11y.skip' => 'Skip to content', + 'nav.topics' => 'Topics', + 'nav.jury' => 'Jury', + 'auth.login' => 'Place your ID card', + 'auth.logout' => 'Sign out', + 'common.date_format' => 'Y-m-d', + 'common.datetime_format' => 'Y-m-d, H:i', + 'common.back_home' => 'Back to start page', + + 'topics.filter_category' => 'Category', + 'topics.filter_all' => 'All', + 'topics.search' => 'Search', + 'topics.sort_net' => 'Highest approval', + 'topics.sort' => 'Sort', + 'topics.sort_new' => 'Newest', + 'topics.sort_top' => 'Most votes', + 'topics.apply' => 'Apply', + 'topics.none' => 'No topics found.', + 'topics.prev' => 'Previous', + 'topics.next' => 'Next', + 'topics.page_of' => 'Page {p} of {n}', + + 'scope.bund' => 'Germany', + 'scope.bundesland' => 'Federal state', + 'scope.landkreis' => 'District', + + 'topic.goal_label' => 'Goal', + 'topic.reasoning_label' => 'Reasoning', + 'topic.report_link' => 'Report content', + 'topic.report_open' => 'Community review in progress.', + 'topic.remember' => 'Save', + 'topic.this' => 'This topic', + 'topic.removed_title' => 'Content removed', + 'topic.removed_text' => 'This contribution was removed after review by a randomly drawn citizen jury.', + 'topic.your_vote' => 'Your vote: {choice}', + + 'vote.for' => 'For', + 'vote.against' => 'Against', + 'vote.withdraw' => 'Withdraw vote', + 'vote.login_hint' => 'Place your ID card to vote', + 'vote.bar_aria' => 'Voting result', + + 'topic.new_title' => 'Raise a topic', + 'common.close' => 'Close', + 'topics.clear' => 'Reset filters', + 'scope.whole' => 'whole', + 'topic.f_end' => 'End of voting', + 'topic.end_by_date' => 'on a date', + 'topic.end_by_target' => 'at a number of votes', + 'topic.end_value_ph' => 'Amount', + 'topic.end_unit' => 'Unit', + 'topic.end_unit_count' => 'X votes', + 'topic.end_unit_percent' => '% votes', + 'topic.err_end' => 'Please set a valid end (future date or target count).', + 'topic.ends_on' => 'Runs until {date}', + 'topic.ends_count' => '{have} of {target} votes', + 'topic.ends_both' => 'Runs until {date} or {have} of {target} votes', + 'topic.ended' => 'ended', + 'topic.archive' => 'Archive topic', + 'topic.archive_confirm' => 'The topic disappears from the lists and can no longer be voted on. It is not deleted.', + 'topic.archived_badge' => 'Archived', + 'topic.archived_note' => 'This topic was archived by its author.', + 'home.recent_votes' => 'Recently voted (still changeable)', + 'vote.changeable_until' => 'changeable until {date}', + 'topic.posted_today' => 'You already raised a topic today. The next one is possible from midnight.', + 'topic.next_in' => 'Next topic in', + 'topic.f_title' => 'Title', + 'topic.f_goal' => 'Goal', + 'topic.f_reasoning' => 'Reasoning', + 'topic.f_category' => 'Category', + 'topic.f_scope' => 'Jurisdiction', + 'topic.f_choose' => 'Please choose', + 'topic.submit' => 'Publish', + 'topic.err_title' => 'Title: at least 8 characters.', + 'topic.err_goal' => 'Goal: at least 10 characters.', + 'topic.err_reasoning' => 'Reasoning: at least 10 characters.', + 'topic.err_category' => 'Please choose a category.', + 'topic.err_scope' => 'Please choose a jurisdiction.', + + 'auth.with' => 'Sign in with {app}', + 'testmode.chip' => 'Test mode', + 'flash.testmode_ended' => 'Live operation configured, test data deleted.', + 'flash.testmode_confirm' => 'Please confirm ending test mode.', + 'setup.title' => 'Set up live operation', + 'setup.checks' => 'Prerequisites', + 'setup.check_data' => 'Data directory writable', + 'setup.check_https' => 'HTTPS active', + 'setup.check_sodium' => 'Cryptography (sodium) available', + 'setup.check_htaccess' => 'Access protection (.htaccess) present', + 'setup.check_keys' => 'Authorised ID keys: {n}', + 'setup.path' => 'Access', + 'setup.path_eid' => 'Own eID server', + 'setup.path_list' => 'Own trust list', + 'setup.f_server' => 'eID server (SOAP address)', + 'setup.f_cert' => 'Client certificate (path)', + 'setup.f_key' => 'Private key (path)', + 'setup.f_client' => 'ID app on the device', + 'setup.f_sync' => 'Sync address of the allowlist', + 'setup.f_nect' => 'Nect Wallet: start address', + 'setup.token' => 'Setup key', + 'setup.token_hint' => 'data/setup.token', + 'setup.check_btn' => 'Test connection', + 'setup.finish_btn' => 'Switch over', + 'setup.confirm' => 'Delete test data', + 'setup.end_locked' => 'Switching only by editing the file.', + 'setup.err_client' => 'The ID app address is invalid.', + 'setup.err_https' => 'Addresses must start with https://.', + 'setup.err_file' => 'Certificate or key is not readable.', + 'setup.err_server_missing' => 'The eID server needs its SOAP address.', + 'setup.err_list_empty' => 'The allowlist is empty: give a sync address or issue ID cards first.', + 'setup.err_token' => 'Setup key does not match.', + 'setup.err_check_first' => 'Please test the connection first.', + 'setup.err_save' => 'Settings could not be saved – check the permissions of the data/ folder.', + 'flash.setup_ok' => 'eID server responds.', + 'flash.setup_failed' => 'eID server does not respond or returns no session.', + 'flash.setup_no_server' => 'No eID server address given.', + 'flash.setup_list_ok' => 'Trust list reachable: {n} keys.', + 'flash.setup_list_failed' => 'Trust list not reachable.', + 'auth.title' => 'Sign in with ID card', + 'auth.tap' => 'Place your ID card', + 'auth.test_login' => 'Start test sign-in', + 'auth.hold' => 'Hold your ID card to the device …', + + 'me.jury_upcoming' => 'Drawn; voting starts {date}, midnight.', + + 'jury.title' => 'Citizen jury', + 'jury.intro' => 'Drawn by lot. Please assess against the criteria; abstaining is allowed.', + 'jury.blocked' => 'Until you vote, the other functions are locked.', + 'jury.none' => 'No jury task.', + 'jury.upcoming' => 'Drawn; voting starts {date}, midnight. Nothing to do until then.', + 'jury.reported' => 'Reported content', + 'jury.law' => 'Reported violation of law', + 'jury.question' => 'Does the content violate the quoted law?', + 'jury.confirm' => 'Yes – remove', + 'jury.reject' => 'No – keep', + 'jury.neutral' => 'Abstain', + 'jury.stats' => '{cast} of {seats} votes cast · quorum: {quorum}', + 'jury.deadline' => 'Regular end: {date}; afterwards a decision is made once the quorum is reached.', + + 'report.title' => 'Report content', + 'report.intro' => 'The only reason to report: violation of a law. The violated section is quoted verbatim.', + 'report.process' => 'A drawn citizen jury decides (1%; from midnight, 24 h, 0.5% quorum).', + 'report.search' => 'Find the law (keyword or section number)', + 'report.pick' => 'Violated law (quoted verbatim)', + 'report.none_found' => 'No match. Try another keyword or section number.', + 'report.submit' => 'Submit report', + 'report.cancel' => 'Cancel', + + 'flash.session_expired' => 'Session ended. Please tap your ID card again.', + 'flash.auth_expired' => 'Sign-in expired – please tap your ID card again.', + 'flash.login_required' => 'Please tap your ID card first.', + 'flash.card_required' => 'Confirmation failed. Please tap your ID card again.', + 'flash.rate_limited' => 'Too many requests. Please wait a moment.', + 'flash.csrf' => 'The request could not be verified. Please try again.', + 'flash.invalid_input' => 'Invalid input.', + 'flash.topic_daily_limit' => 'Topic already raised today; the next one from midnight.', + 'flash.topic_created' => 'Topic published.', + 'flash.topic_not_votable' => 'Voting not possible.', + 'flash.topic_not_reportable' => 'Reporting not possible.', + 'flash.vote_saved' => 'Vote saved.', + 'flash.vote_withdrawn' => 'Vote withdrawn.', + 'flash.favorite_added' => 'Favourite added.', + 'flash.favorite_removed' => 'Favourite removed.', + 'flash.report_already_open' => 'A review is already in progress for this topic.', + 'flash.report_duplicate' => 'You have already reported this topic.', + 'flash.report_daily_limit' => 'Daily report limit reached.', + 'flash.report_too_few_users' => 'Too few participants are registered for a jury at the moment.', + 'flash.report_no_law' => 'Please select the violated law.', + 'flash.not_author' => 'Only the author can change this topic.', + 'flash.topic_locked' => 'Once votes are cast the topic stays permanently.', + 'topic.similar' => 'Similar topics', + 'topic.similar_hint' => 'Similar topics already exist for this title. You can still publish it.', + 'flash.topic_archived' => 'Topic archived.', + 'flash.vote_locked' => 'This vote can no longer be changed after 24 hours.', + 'flash.report_created' => 'Report received. The jury has been drawn; voting starts at midnight.', + 'flash.jury_not_open' => 'This vote is not (or no longer) open.', + 'flash.jury_not_member' => 'No authorisation for this jury.', + 'flash.jury_already_voted' => 'Already voted in this review.', + 'flash.jury_voted' => 'Jury vote counted.', + 'flash.auth_failed' => 'Sign-in failed.', + 'flash.eid_required' => 'Sign-in only with a real ID card via an ID app.', + 'flash.eid_provider_off' => 'This provider is not set up in this installation yet.', + 'flash.no_card' => 'No ID card present. Please provide an ID card.', + 'flash.card_not_authorized' => 'This ID card is not authorised.', + 'flash.card_ready' => 'ID card ready. Tap to sign in.', + 'flash.card_new' => 'Ready for a different ID card. Tap to sign in.', + 'flash.logged_out' => 'Signed out.', + + 'error.not_found_title' => 'Page not found', + 'error.not_found' => 'The requested page does not exist or has been removed.', + 'error.generic_title' => 'Error', + 'error.generic' => 'An error occurred. Please try again later.', + 'error.method' => 'Request method not supported.', + + 'footer.imprint' => 'Legal notice', + 'footer.privacy' => 'Privacy', + + 'imprint.h' => 'Legal notice', + 'imprint.p1' => "Florian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + 'imprint.p2' => 'E-mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Responsible for the content:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]", + + 'privacy.h' => 'Privacy policy', + 'privacy.p1' => 'This application does not process clear identities such as name, address, date of birth or e-mail address.', + 'privacy.p2' => 'A technical profile is created in order to use the site. This profile and the entries, votes, topics, reports and decisions submitted on the site are stored permanently. Permanent storage serves uniqueness, traceability, the prevention of duplicate or retroactively manipulated operations, and the security of the application.', + 'privacy.p3' => 'For recognition the application uses pseudonymous technical identifiers, for example hash or HMAC values with a server-side secret. These identifiers serve sign-in, session handling, abuse prevention and the prevention of duplicate votes.', + 'privacy.p4' => 'The site uses one technically necessary session cookie for sign-in and security. No further cookies, tracking, advertising or external third-party content are used.', + 'privacy.p5' => 'Technical log data may be processed for abuse prevention and error analysis. It is used for security and operation only.', + 'privacy.p6' => 'The legal basis, where required, is Art. 6(1)(f) GDPR. The legitimate interest lies in the secure operation of the application, the uniqueness of the votes and protection against manipulation.', + 'privacy.p7' => 'Data subjects have the rights to information, rectification, erasure, restriction of processing, objection and to lodge a complaint with a supervisory authority, as provided by the GDPR.', + 'privacy.p8' => 'Erasure and restriction of processing may be refused or carried out only in part where continued storage is necessary for integrity, traceability, abuse prevention, prevention of duplicate votes or prevention of retroactive manipulation.', + 'privacy.p9' => 'Users are responsible for the content they submit. The operator does not adopt this content as its own. Unlawful content may be reviewed, restricted or removed once it becomes known.', + 'privacy.p10' => "Responsible:\nFlorian Kutzer\nc/o ihr-impressum.de / Order 10178\n[Adresse aus deinem ihr-impressum.de-Kundenkonto]\nE-mail: datenschutz@buergerabstimmung.org", + 'consent.text' => 'This site uses one technically necessary session cookie for sign-in and security. See the privacy policy for details.', + 'consent.accept' => 'Accept', + 'consent.privacy' => 'Privacy policy', + 'error.consent' => 'This needs the session cookie. Please accept below.', +]; + +const SW_CSS = <<<'CSS' +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { margin: 0 auto; max-width: 42rem; padding: 0 1rem 3rem; font: 1rem/1.5 system-ui, sans-serif; } +h1 { font-size: 1.5rem; margin: .6rem 0 .4rem; } +h2 { font-size: 1.15rem; } +h3 { font-size: 1rem; margin: 0 0 .2rem; } +p { margin: .4rem 0; } +img, svg { max-width: 100%; } +.muted, .link-quiet, .field-label, .card-h, .law-quote { color: GrayText; } +.field-label, .card-h { font-size: .8rem; text-transform: uppercase; margin: .8rem 0 .2rem; } +.skip-link { position: absolute; left: -999px; } +.skip-link:focus { left: .5rem; } +.test-banner { background: #ffd60a; color: #000; text-align: center; font-size: .8rem; padding: .3rem; margin: 0 -1rem; } +.site-header { display: flex; justify-content: flex-end; gap: .5rem; padding: .5rem 0; border-bottom: 1px solid; } +.header-controls { display: flex; align-items: center; gap: .5rem; } +.testmode-chip { background: #ffd60a; color: #000; padding: .2rem .5rem; text-decoration: none; } + +.card, .row-list, .flash, .similar-hint, .criteria-set, .end-fields { border: 1px solid GrayText; padding: .7rem; margin: .7rem 0; } +.row-list, .check-list, .plain-list { list-style: none; } +.row-list, .check-list { padding: 0 .7rem; } +.row-item { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .5rem; padding: .5rem 0; } +.row-item + .row-item { border-top: 1px solid GrayText; } + +.btn, .lang-btn, .fav-item, .tool-btn, .fav-menu > summary { + display: inline-block; font: inherit; padding: .45rem .8rem; margin: .15rem .15rem 0 0; + border: 1px solid; background: transparent; color: LinkText; cursor: pointer; text-decoration: none; +} +.btn-primary { background: Highlight; color: HighlightText; border-color: Highlight; } +.btn-danger, .tool-btn.is-report, .tool-btn.is-open, .flash-error, .badge-danger { color: #c00; } +.btn-big, .provider-btn { display: block; width: 100%; text-align: center; } +.tool-btn, .fav-menu > summary { padding: .3rem .4rem; line-height: 0; } +.fav-menu { display: inline-block; position: relative; } +.fav-menu > summary { list-style: none; } +.fav-menu > summary::-webkit-details-marker { display: none; } +.fav-pop { position: absolute; right: 0; z-index: 60; background: Canvas; border: 1px solid GrayText; padding: .3rem; min-width: 11rem; } +.fav-item { display: block; width: 100%; text-align: left; border: 0; margin: 0; } +.fav-item.is-on { font-weight: 700; } +.ico { width: 1.1rem; height: 1.1rem; } + +.badge { border: 1px solid GrayText; padding: 0 .4rem; font-size: .8rem; color: GrayText; } +.topic-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: .3rem; } +.topic-tools { margin-left: auto; white-space: nowrap; } +.topic-card-title { margin: .3rem 0 .2rem; } +.topic-card-title a { color: inherit; } + +.votebar { display: flex; height: .6rem; border: 1px solid GrayText; } +.votebar span { flex-basis: 0; } +.votebar-for, .dot-for { background: Highlight; } +.votebar-against, .dot-against { background: GrayText; } +.votefig-slim .votebar { height: .3rem; } +.dot { display: inline-block; width: .5rem; height: .5rem; margin-right: .3rem; } +.votebar-legend > span { margin-right: 1.2rem; } +.pct { color: GrayText; margin-left: .3rem; } +.vote-btn.is-active { background: Highlight; color: HighlightText; border-color: Highlight; } +.vote-btn.is-locked { color: Highlight; } +.vote-btn.is-dim { color: GrayText; } + +.form-stack > label, .form-row label, .check-label, .end-fields .check { display: block; margin: .6rem 0; } +.check-label, .end-fields .check { display: flex; align-items: center; gap: .5rem; } +label > span { display: block; font-size: .9rem; } +.check-label > span, .end-fields .check > span { display: inline; } +input[type="text"], input[type="search"], input[type="date"], input[type="number"], textarea, select { + font: inherit; width: 100%; padding: .4rem; border: 1px solid GrayText; background: Field; color: FieldText; +} +.check-label input, .end-fields .check input { width: auto; } +.end-row input[type="number"] { width: 7rem; } +.hr-soft { border: 0; border-top: 1px solid GrayText; } + +.auth-card, .error-card, .start-gate { text-align: center; } +.tap-icon { width: 5rem; height: auto; } +.flag { width: 3.5rem; height: auto; } +.start-brand { font-size: 1.5rem; font-weight: 700; } +.start-langs { display: flex; gap: .8rem; justify-content: center; } +.lang-btn { display: flex; flex-direction: column; align-items: center; gap: .4rem; } +.site-footer { border-top: 1px solid GrayText; margin-top: 2rem; padding-top: .6rem; font-size: .85rem; } +.footer-nav a { margin-right: 1rem; } + +.modal { display: none; position: fixed; inset: 0; z-index: 200; overflow: auto; } +.modal:target { display: block; } +.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); } +.modal-box { position: relative; background: Canvas; max-width: 34rem; margin: 2rem auto; padding: 1rem; border: 1px solid GrayText; } +.modal-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; } +.modal-head h2 { margin: 0; } +.path-set input[type="radio"] { float: left; margin: .25rem .5rem 0 0; } +.path-label { display: block; overflow: hidden; margin: 0 0 .4rem; } +.only-eid, .only-list { display: none; clear: both; } +#path-eid:checked ~ .only-eid, #path-list:checked ~ .only-list { display: flex; flex-direction: column; gap: .6rem; } +.form-stack > .check-label { display: flex; flex-direction: row; align-items: center; gap: .5rem; } +.check-list li { display: flex; gap: .5rem; } +.btn:disabled { opacity: .5; } +.consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 300; background: Canvas; border-top: 1px solid GrayText; padding: .7rem 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; font-size: .9rem; } +.site-footer { margin-bottom: 4.5rem; } +CSS; + +const SW_JS = <<<'JS' + +(function () { + 'use strict'; + var init = function () { + + var nodes = document.querySelectorAll('[data-countdown-to]'); + if (nodes.length > 0) { + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + var update = function () { + nodes.forEach(function (node) { + var target = Date.parse(node.getAttribute('data-countdown-to').replace(' ', 'T') + 'Z'); + var diff = Math.max(0, Math.floor((target - Date.now()) / 1000)); + var text = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60); + node.textContent = (node.getAttribute('data-label') || '') + ' ' + text; + }); + }; + update(); + setInterval(update, 1000); + } + + document.querySelectorAll('select[data-scope-native]').forEach(function (native) { + var current = native.value; + var level = 'de', land = '', kreis = ''; + if (current.indexOf('bl:') === 0) { level = 'bundesland'; land = current.slice(3); } + else if (current.indexOf('kr:') === 0) { level = 'landkreis'; var r = current.slice(3).split(':'); land = r[0]; kreis = r.slice(1).join(':'); } + else if (current === '') { level = native.querySelector('option[value=""]') ? 'all' : 'de'; } + var lands = {}, order = []; + native.querySelectorAll('optgroup').forEach(function (g) { + var name = g.label; order.push(name); lands[name] = []; + g.querySelectorAll('option').forEach(function (o) { + if (o.value.indexOf('kr:') === 0) { lands[name].push(o.textContent); } + }); + }); + var L = { + de: native.getAttribute('data-l-de') || 'DE', + bl: native.getAttribute('data-l-bl') || 'Bundesland', + kr: native.getAttribute('data-l-kr') || 'Landkreis', + pick: native.getAttribute('data-l-pick') || '—', + all: native.getAttribute('data-l-all') || '—' + }; + var wrap = document.createElement('div'); + wrap.className = 'scope-picker'; + var hasAll = !!native.querySelector('option[value=""]'); + var selLevel = document.createElement('select'); + if (hasAll) { selLevel.add(new Option(L.all, 'all')); } + selLevel.add(new Option(L.de, 'de')); + selLevel.add(new Option(L.bl, 'bundesland')); + selLevel.add(new Option(L.kr, 'landkreis')); + selLevel.value = level; + var selLand = document.createElement('select'); + selLand.add(new Option(L.pick, '')); + order.forEach(function (n) { selLand.add(new Option(n, n)); }); + if (land) { selLand.value = land; } + var selKreis = document.createElement('select'); + var fillKreis = function () { + selKreis.innerHTML = ''; + selKreis.add(new Option(L.pick, '')); + (lands[selLand.value] || []).forEach(function (k) { selKreis.add(new Option(k, k)); }); + if (kreis) { selKreis.value = kreis; } + }; + fillKreis(); + var sync = function () { + var v = 'de'; + if (selLevel.value === 'all') { v = ''; } + else if (selLevel.value === 'de') { v = 'de'; } + else if (selLevel.value === 'bundesland') { v = selLand.value ? 'bl:' + selLand.value : ''; } + else if (selLevel.value === 'landkreis') { v = (selLand.value && selKreis.value) ? 'kr:' + selLand.value + ':' + selKreis.value : ''; } + native.value = v; + selLand.hidden = !(selLevel.value === 'bundesland' || selLevel.value === 'landkreis'); + selKreis.hidden = selLevel.value !== 'landkreis'; + }; + selLevel.addEventListener('change', sync); + selLand.addEventListener('change', function () { kreis = ''; fillKreis(); sync(); }); + selKreis.addEventListener('change', sync); + native.style.display = 'none'; + native.parentNode.insertBefore(wrap, native); + wrap.appendChild(selLevel); wrap.appendChild(selLand); wrap.appendChild(selKreis); + sync(); + }); + + document.querySelectorAll('[data-end-fields]').forEach(function (fs) { + fs.querySelectorAll('[data-end-toggle]').forEach(function (box) { + var part = fs.querySelector('[data-end-part="' + box.getAttribute('data-end-toggle') + '"]'); + if (!part) return; + var apply = function () { part.hidden = !box.checked; }; + box.addEventListener('change', apply); apply(); + }); + }); + + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && location.hash && document.querySelector(location.hash + '.modal')) { + location.hash = ''; + } + }); + + var base = document.body.getAttribute('data-base') || ''; + var figures = document.querySelectorAll('[data-topic]'); + if (figures.length > 0) { + var ids = []; + figures.forEach(function (el) { ids.push(el.getAttribute('data-topic')); }); + var groupSep = document.body.getAttribute('data-group-sep') || ''; + var groupNum = function (n) { + return groupSep === '' ? String(n) : String(n).replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + }; + var setNum = function (el, sel, value) { + var node = el.querySelector(sel); + if (node && node.textContent !== value) { node.textContent = value; } + }; + var refresh = function () { + fetch(base + '/api/topics?ids=' + encodeURIComponent(ids.join(',')), { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + if (!data) { return; } + figures.forEach(function (el) { + var row = data[el.getAttribute('data-topic')]; + if (!row) { return; } + var total = row.f + row.a; + var pf = total > 0 ? Math.round(row.f * 100 / total) : 0; + var pa = total > 0 ? 100 - pf : 0; + var bf = el.querySelector('[data-bar="for"]'); + var ba = el.querySelector('[data-bar="against"]'); + if (bf) { bf.className = 'votebar-for w-' + pf; } + if (ba) { ba.className = 'votebar-against w-' + pa; } + setNum(el, '[data-num="for"]', groupNum(row.f)); + setNum(el, '[data-num="against"]', groupNum(row.a)); + setNum(el, '[data-pct="for"]', '/ ' + pf + ' %'); + setNum(el, '[data-pct="against"]', '/ ' + pa + ' %'); + }); + }) + .catch(function () {}); + }; + var timer = setInterval(refresh, 12000); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') { refresh(); } + }); + window.addEventListener('pagehide', function () { clearInterval(timer); }); + } + + document.querySelectorAll('[data-similar-for]').forEach(function (box) { + var input = document.querySelector(box.getAttribute('data-similar-for')); + if (!input) { return; } + var exclude = box.getAttribute('data-similar-not') || ''; + var wait = null; + var lookup = function () { + var q = input.value.trim(); + if (q.length < 6) { box.hidden = true; box.innerHTML = ''; return; } + fetch(base + '/api/similar?q=' + encodeURIComponent(q) + (exclude ? '¬=' + encodeURIComponent(exclude) : ''), + { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (rows) { + if (!rows || rows.length === 0) { box.hidden = true; box.innerHTML = ''; return; } + var list = document.createElement('ul'); + list.className = 'plain-list'; + rows.forEach(function (row) { + var li = document.createElement('li'); + var a = document.createElement('a'); + a.href = base + '/topic/' + row.id; + a.textContent = row.title; + li.appendChild(a); + list.appendChild(li); + }); + var head = document.createElement('p'); + head.className = 'muted'; + head.textContent = box.getAttribute('data-similar-label') || ''; + box.innerHTML = ''; + box.appendChild(head); + box.appendChild(list); + box.hidden = false; + }) + .catch(function () {}); + }; + input.addEventListener('input', function () { + clearTimeout(wait); + wait = setTimeout(lookup, 400); + }); + }); + + var profileUrl = document.body.getAttribute('data-profile-url'); + if (profileUrl) { + fetch(profileUrl, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : null; }) + .then(function (text) { + try { + if (text) { sessionStorage.setItem('profil.yaml', text); } + } catch (e) {} + }) + .catch(function () {}); + } + var logoutForms = document.querySelectorAll('form.js-logout'); + logoutForms.forEach(function (form) { + form.addEventListener('submit', function () { + try { sessionStorage.removeItem('profil.yaml'); } catch (e) {} + }); + }); + + var tapForm = document.getElementById('tap-form'); + if (tapForm && 'NDEFReader' in window) { + var status = document.getElementById('tap-status'); + var done = false; + var go = function () { + if (!done) { done = true; tapForm.submit(); } + }; + var startScan = function () { + var reader = new NDEFReader(); + reader.addEventListener('reading', go); + reader.addEventListener('readingerror', go); + return reader.scan(); + }; + + try { + startScan().then(function () { + + if (status) { status.hidden = false; } + tapForm.querySelectorAll('[data-nfc-hide]').forEach(function (b) { b.hidden = true; }); + }).catch(function () { }); + } catch (e) { } + + tapForm.addEventListener('submit', function (ev) { + if (tapForm.getAttribute('data-armed') === '1') { return; } + ev.preventDefault(); + tapForm.setAttribute('data-armed', '1'); + if (status) { status.hidden = false; } + try { + startScan().catch(go); + } catch (e) { go(); } + }); + } + }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } + else { init(); } +})(); +JS; + +const SW_ICON = <<<'SVG' + + + + + +SVG; + +function flag_de(): string +{ + return ''; +} + +function flag_en(): string +{ + return ''; +} + +function icon_links(): string +{ + return '' + . '' + . ''; +} + +function png_chunk(string $type, string $data): string +{ + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); +} + +function icon_png(int $size): string +{ + $ss = 3; + $n = $size * $ss; + $bg = [17, 17, 17]; + $fg = [255, 255, 255]; + $a1 = [0.265 * $n, 0.515 * $n]; + $b1 = [0.430 * $n, 0.680 * $n]; + $a2 = [0.430 * $n, 0.680 * $n]; + $b2 = [0.735 * $n, 0.340 * $n]; + $half = 0.058 * $n; + + $inBar = static function (float $px, float $py, array $a, array $b, float $half): bool { + $vx = $b[0] - $a[0]; + $vy = $b[1] - $a[1]; + $len = sqrt($vx * $vx + $vy * $vy); + if ($len <= 0.0) { + return false; + } + $ux = $vx / $len; + $uy = $vy / $len; + $wx = $px - $a[0]; + $wy = $py - $a[1]; + $along = $wx * $ux + $wy * $uy; + $perp = abs($wx * -$uy + $wy * $ux); + return $perp <= $half && $along >= -$half && $along <= $len + $half; + }; + + $raw = ''; + for ($y = 0; $y < $size; $y++) { + $raw .= "\x00"; + for ($x = 0; $x < $size; $x++) { + $rSum = 0; $gSum = 0; $bSum = 0; + for ($sy = 0; $sy < $ss; $sy++) { + for ($sx = 0; $sx < $ss; $sx++) { + $px = $x * $ss + $sx + 0.5; + $py = $y * $ss + $sy + 0.5; + $on = $inBar($px, $py, $a1, $b1, $half) || $inBar($px, $py, $a2, $b2, $half); + $col = $on ? $fg : $bg; + $rSum += $col[0]; $gSum += $col[1]; $bSum += $col[2]; + } + } + $total = $ss * $ss; + $raw .= chr((int) round($rSum / $total)) . chr((int) round($gSum / $total)) + . chr((int) round($bSum / $total)) . chr(255); + } + } + $ihdr = pack('NN', $size, $size) . chr(8) . chr(6) . chr(0) . chr(0) . chr(0); + return "\x89PNG\r\n\x1a\n" + . png_chunk('IHDR', $ihdr) + . png_chunk('IDAT', gzcompress($raw, 9)) + . png_chunk('IEND', ''); +} + +function icon_ico(int $size = 32): string +{ + $png = icon_png($size); + $dim = $size >= 256 ? 0 : $size; + return pack('vvv', 0, 1, 1) + . chr($dim) . chr($dim) . chr(0) . chr(0) + . pack('vv', 1, 32) + . pack('VV', strlen($png), 22) + . $png; +} + +function serve_asset(string $kind): void +{ + $map = [ + 'css' => ['text/css; charset=utf-8', SW_CSS], + 'js' => ['text/javascript; charset=utf-8', SW_JS], + 'icon' => ['image/svg+xml', SW_ICON], + ]; + if (!isset($map[$kind])) { + http_response_code(404); + exit; + } + header('Content-Type: ' . $map[$kind][0]); + header('Cache-Control: public, max-age=3600'); + header('X-Content-Type-Options: nosniff'); + echo $map[$kind][1]; + if ($kind === 'css') { + + for ($i = 0; $i <= 100; $i++) { + echo "\n.w-" . $i . ' { flex-grow: ' . $i . '; }'; + } + } + exit; +} + +function render(string $title, string $content, int $status = 200): void +{ + http_response_code($status); + echo v_layout($title, $content); + exit; +} + +function v_layout(string $title, string $content): string +{ + $cfg = SW::$cfg; + $user = auth_user(); + $duty = $user === null ? null : jury_pending_for((int) $user['id']); + $query = (string) ($_SERVER['QUERY_STRING'] ?? ''); + $returnValue = SW::$path . ($query !== '' ? '?' . $query : ''); + $b = base_path(); + $a = SW::$base; + + $html = '' + . '' + . '' + . '' . e($title) . ' · ' . e((string) $cfg['app_name']) . '' + . '' + . icon_links() + . '' + . ''; + if (!empty($cfg['show_test_banner'])) { + $html .= '
' . e(t('banner.test')) . '
'; + } + $html .= '' + . '' + ; + + $flashes = take_flashes(); + if ($flashes !== []) { + $html .= '
'; + foreach ($flashes as $f) { + $html .= '
' + . e(t((string) $f['key'], (array) $f['repl'])) . '
'; + } + $html .= '
'; + } + $html .= '
' . $content . '
' + . '' + . (consent_given() ? '' : '') + . ''; + return $html; +} + +function scope_text(array $row): string +{ + $name = (string) ($row['scope_name'] ?? ''); + return $name !== '' ? $name : t('scope.bund'); +} + +function p_topic_card(array $row): string +{ + $html = '
' + . '' . e(cat_name($row)) . '' + . '' . e(scope_text($row)) . '
' + . '

' . e((string) $row['title']) . '

' + . '

' . e((string) $row['goal']) . '

' + . p_votebar((int) $row['votes_for'], (int) $row['votes_against'], true); + if (!empty($row['my_choice'])) { + $html .= '

' + . e(t('topic.your_vote', ['choice' => t($row['my_choice'] === 'for' ? 'vote.for' : 'vote.against')])) . '

'; + } + return $html . '
'; +} + +function p_vote_button(string $choice, ?string $myVote): string +{ + $mine = $myVote === $choice; + return ''; +} + +function p_vote_locked(?string $myVote): string +{ + $html = '
'; + foreach (['for', 'against'] as $choice) { + $mine = $myVote === $choice; + $state = $myVote === null ? ' is-dim' : ($mine ? ' is-locked' : ' is-dim'); + $html .= ''; + } + return $html . '
'; +} + +function p_votebar(int $for, int $against, bool $slim = false): string +{ + $total = $for + $against; + $pctFor = $total > 0 ? (int) round($for * 100 / $total) : 0; + $pctAgainst = $total > 0 ? 100 - $pctFor : 0; + $cls = $slim ? 'votefig votefig-slim' : 'votefig'; + return '
' + . '
' + . '' . e(t('vote.for')) + . ' ' . e(num($for)) . '' + . '/ ' . $pctFor . ' %' + . '' . e(t('vote.against')) + . ' ' . e(num($against)) . '' + . '/ ' . $pctAgainst . ' %' + . '
'; +} + +function topic_end_fields(array $old): string +{ + $byDate = (bool) ($old['end_by_date'] ?? true); + $byTarget = (bool) ($old['end_by_target'] ?? false); + if (!$byDate && !$byTarget) { + $byDate = true; + } + $defaultDate = substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10); + $date = ($old['end_date'] ?? '') !== '' ? (string) $old['end_date'] : $defaultDate; + $value = ($old['end_value'] ?? '') !== '' ? (string) $old['end_value'] : ''; + $unit = ($old['end_unit'] ?? '') === 'percent' ? 'percent' : 'count'; + $minDate = substr(Clock::addDaysStr(Clock::nowStr(), 1), 0, 10); + $maxDate = substr(Clock::addDaysStr(Clock::nowStr(), 365), 0, 10); + return '
' . e(t('topic.f_end')) . '' + . '' + . '
' + . '' + . '
' + . '' + . '
' + . '' + . '
' + . '
'; +} + +function topic_form_html(array $errors, array $old, string $action, string $submitKey, ?int $selfId = null): string +{ + $formId = $selfId === null ? 'new' : ('e' . $selfId); + $html = ''; + if ($errors !== []) { + $html .= ''; + } + $html .= '
' . csrf_field() + . '' + . '' + . '' + . '' + . '
' + . '
' + . topic_end_fields($old) + . '
'; + return $html; +} + +function modal(string $id, string $title, string $inner): string +{ + return ''; +} + +function v_main(array $formErrors = [], ?array $formOld = null): void +{ + $user = auth_user(); + $userId = $user === null ? null : (int) $user['id']; + $html = ''; + + $upcoming = $userId === null ? null : jury_upcoming_for($userId); + if ($upcoming !== null) { + $html .= '
' . e(t('me.jury_upcoming', ['date' => Clock::displayLocal((string) $upcoming['voting_starts_at'], t('common.date_format'))])) . '
'; + } + + $scopeValue = query_str('gebiet', 160); + $scopeDecoded = $scopeValue === '' ? null : scope_decode($scopeValue); + if ($scopeDecoded === null) { + $scopeValue = ''; + } + $filters = [ + 'category' => query_str('category', 64), + 'level' => $scopeDecoded === null ? '' : $scopeDecoded[0], + 'scope' => $scopeDecoded === null || $scopeDecoded[1] === null ? '' : $scopeDecoded[1], + 'gebiet' => $scopeValue, + 'q' => query_str('q', 80), + 'sort' => in_array(query_str('sort', 10), ['new', 'top'], true) ? query_str('sort', 10) : 'net', + ]; + $page = query_int('page', 1, 500, 1); + $perPage = (int) SW::$cfg['page_size']; + $result = topics_list($filters, $page, $perPage, $userId); + $pages = max(1, (int) ceil($result['total'] / $perPage)); + + $html .= '
'; + if ($userId !== null) { + $html .= '' . e(t('topic.new_title')) . ''; + } + $html .= '' . e(t('topics.search')) . ''; + if ($filters['q'] !== '' || $filters['category'] !== '' || $filters['gebiet'] !== '') { + $html .= '' . e(t('topics.clear')) . ''; + } + $html .= '
'; + + $chips = ''; + foreach ($userId === null ? [] : fav_list($userId) as $favorite) { + if ($favorite['kind'] === 'topic') { + if (($favorite['topic_title'] ?? null) === null) { + continue; + } + $label = (string) $favorite['topic_title']; + if (mb_strlen($label) > 34) { + $label = mb_substr($label, 0, 33) . '…'; + } + $href = url('/topic/' . (int) $favorite['ref']); + } elseif ($favorite['kind'] === 'category') { + $label = SW::$lang === 'de' ? (string) ($favorite['name_de'] ?? $favorite['ref']) : (string) ($favorite['name_en'] ?? $favorite['ref']); + $href = url('/') . '?category=' . rawurlencode((string) $favorite['ref']); + } else { + $gebiet = fav_to_gebiet((string) $favorite['ref']); + if ($gebiet === null) { + continue; + } + $parts = explode(':', (string) $favorite['ref'], 2); + $label = $parts[0] === 'bund' || !isset($parts[1]) ? t('scope.bund') : $parts[1]; + $href = url('/') . '?gebiet=' . rawurlencode($gebiet); + } + $chips .= '' . icon_bookmark(true) . e($label) . ''; + } + if ($chips !== '') { + $html .= '
' . $chips . '
'; + } + + $recent = []; + foreach ($userId === null ? [] : topics_voted_by($userId) as $row) { + if (!in_array((string) $row['status'], ['removed', 'archived'], true) && empty($row['locked'])) { + $recent[] = $row; + } + } + if ($recent !== []) { + $html .= '

' . e(t('home.recent_votes')) . '

    '; + foreach ($recent as $row) { + $until = Clock::addHoursStr((string) $row['voted_at'], SW_VOTE_CHANGE_HOURS); + $html .= '
  • ' + . '
    ' + . e(t($row['my_choice'] === 'for' ? 'vote.for' : 'vote.against')) + . ' · ' . e(t('vote.changeable_until', ['date' => Clock::displayLocal($until, t('common.datetime_format'))])) . '
  • '; + } + $html .= '
'; + } + + if ($result['rows'] === []) { + $html .= '

' . e(t('topics.none')) . '

'; + } else { + $html .= '
'; + foreach ($result['rows'] as $row) { + $html .= p_topic_card($row); + } + $html .= '
'; + } + if ($pages > 1) { + $mkQuery = static function (int $p) use ($filters): string { + $keep = ['category' => $filters['category'], 'gebiet' => $filters['gebiet'], + 'q' => $filters['q'], 'sort' => $filters['sort'], 'page' => $p]; + $params = array_filter($keep, static function ($v) { + return $v !== '' && $v !== null; + }); + return $params === [] ? '' : '?' . http_build_query($params); + }; + $html .= ''; + } + + if ($userId !== null) { + $old = $formOld ?? ['title' => '', 'goal' => '', 'reasoning' => '', 'category_id' => 0, 'scope' => 'de', + 'end_by_date' => true, 'end_date' => '', 'end_by_target' => false, + 'end_value' => '', 'end_unit' => 'count']; + if (topic_has_posted_today($userId)) { + $newInner = '

' . e(t('topic.posted_today')) + . '

'; + } else { + $newInner = topic_form_html($formErrors, $old, '/topics', 'topic.submit'); + } + $html .= modal('modal-new', t('topic.new_title'), $newInner); + } + + $searchInner = '
' + . '' + . '' + . '' + . '' + . '
'; + $html .= modal('modal-search', t('topics.search'), $searchInner); + + render(t('app.tagline'), $html); +} + +function topic_end_text(array $topic): string +{ + if ($topic['status'] === 'archived') { + return t('topic.archived_badge'); + } + if ($topic['status'] === 'closed') { + return t('topic.ended'); + } + $date = $topic['end_date'] !== null + ? Clock::displayLocal((string) $topic['end_date'] . ' 00:00:00', t('common.date_format')) + : null; + $target = $topic['end_target'] !== null ? (int) $topic['end_target'] : null; + $have = num((int) $topic['votes_for'] + (int) $topic['votes_against']); + if ($date !== null && $target !== null) { + return t('topic.ends_both', ['date' => $date, 'have' => $have, 'target' => num($target)]); + } + if ($date !== null) { + return t('topic.ends_on', ['date' => $date]); + } + if ($target !== null) { + return t('topic.ends_count', ['have' => $have, 'target' => num($target)]); + } + return ''; +} + +function fav_menu(int $userId, int $topicId, string $catRef, string $catLabel, string $scopeRef, string $scopeLabel): string +{ + $back = '/topic/' . $topicId; + $items = [ + ['topic', (string) $topicId, t('topic.this'), fav_is($userId, 'topic', (string) $topicId)], + ['category', $catRef, $catLabel, fav_is($userId, 'category', $catRef)], + ['scope', $scopeRef, $scopeLabel, fav_is($userId, 'scope', $scopeRef)], + ]; + $any = false; + foreach ($items as $item) { + $any = $any || $item[3]; + } + $html = '
' + . icon_bookmark($any) . '
'; + foreach ($items as [$kind, $ref, $label, $on]) { + $html .= '
' . csrf_field() + . '' + . '' + . '' + . '
'; + } + return $html . '
'; +} + +function icon_archive(): string +{ + return ''; +} + +function icon_flag(): string +{ + return ''; +} + +function icon_bookmark(bool $filled): string +{ + return ''; +} + +function v_topic(int $id): void +{ + $topic = topic_find($id); + if ($topic === null) { + v_error_404(); + } + topic_close_if_due($topic); + $topic = topic_find($id); + if ($topic['status'] === 'removed') { + $html = ''; + render(t('topic.removed_title'), $html); + } + $user = auth_user(); + $userId = $user === null ? null : (int) $user['id']; + $voteRow = $userId === null ? null : topic_user_vote_row($id, $userId); + $myVote = $voteRow === null ? null : (string) $voteRow['choice']; + $isAuthor = $userId !== null && (int) $topic['author_id'] === $userId; + $openReport = report_open_for($id); + $archived = $topic['status'] === 'archived'; + $closed = $topic['status'] !== 'active'; + $scopeRef = $topic['scope_level'] === 'bund' ? 'bund' : $topic['scope_level'] . ':' . (string) $topic['scope_name']; + + $locked = topic_has_votes((int) $topic['id']); + $tools = ''; + if ($user !== null) { + $tools .= fav_menu( + (int) $user['id'], + (int) $topic['id'], + (string) $topic['category_slug'], + cat_name($topic), + $scopeRef, + scope_text($topic) + ); + } + if ($isAuthor && !$archived && !$locked) { + $tools .= '' . icon_archive() . ''; + } + if ($openReport !== null) { + $tools .= '' . icon_flag() . ''; + } elseif ($user !== null && !$isAuthor && !$closed) { + $tools .= '' + . icon_flag() . ''; + } + + $html = '
' + . '' . e(cat_name($topic)) . '' + . '' . e(scope_text($topic)) . '' + . ($archived ? '' . e(t('topic.archived_badge')) . '' + : ($closed ? '' . e(t('topic.ended')) . '' : '')) + . ($tools !== '' ? '
' . $tools . '
' : '') + . '
' + . '

' . e((string) $topic['title']) . '

' + . '

' . e(Clock::displayLocal((string) $topic['created_at'], t('common.date_format'))) + . ' · ' . e(topic_end_text($topic)) . '

' + . '

' . e(t('topic.goal_label')) . '

' + . '

' . nl2br(e((string) $topic['goal'])) . '

' + . '

' . e(t('topic.reasoning_label')) . '

' + . '

' . nl2br(e((string) $topic['reasoning'])) . '

'; + + $barFor = (int) $topic['votes_for']; + $barAgainst = (int) $topic['votes_against']; + $html .= '
' . p_votebar($barFor, $barAgainst); + if ($archived) { + $html .= '

' . e(t('topic.archived_note')) . '

'; + } elseif ($user === null) { + $html .= '

' . e(t('vote.login_hint')) . '

'; + } elseif ($closed || ($voteRow !== null && $voteRow['locked'])) { + $html .= p_vote_locked($myVote); + } else { + $html .= '
' . csrf_field() + . '' + . p_vote_button('for', $myVote) . p_vote_button('against', $myVote); + if ($myVote !== null) { + $html .= ''; + } + $html .= '
'; + } + $html .= '
'; + $similar = topics_similar((string) $topic['title'], (int) $topic['id'], 4); + if ($similar !== []) { + $html .= '

' . e(t('topic.similar')) . '

'; + } + $html .= '
'; + + if ($isAuthor && !$locked && !$archived) { + $delInner = '

' . e(t('topic.archive_confirm')) . '

' + . '
' . csrf_field() + . '
' + . '' . e(t('common.close')) . '
'; + $html .= modal('modal-del', t('topic.archive'), $delInner); + } + render((string) $topic['title'], $html); +} + +function h_api_topics(): void +{ + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + header('X-Content-Type-Options: nosniff'); + $ids = []; + foreach (explode(',', query_str('ids', 400)) as $raw) { + if (preg_match('/^\d{1,10}$/', trim($raw)) === 1) { + $ids[] = (int) $raw; + } + } + $ids = array_slice(array_unique($ids), 0, 50); + if ($ids === []) { + echo '{}'; + exit; + } + $marks = implode(',', array_fill(0, count($ids), '?')); + $rows = SW::$db->all( + "SELECT t.id, t.status, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'for') AS votes_for, + (SELECT COUNT(*) FROM votes v WHERE v.topic_id = t.id AND v.choice = 'against') AS votes_against + FROM topics t WHERE t.id IN ($marks)", + $ids + ); + $out = []; + foreach ($rows as $row) { + $out[(string) $row['id']] = [ + 'f' => (int) $row['votes_for'], + 'a' => (int) $row['votes_against'], + 's' => (string) $row['status'], + ]; + } + echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function h_api_similar(): void +{ + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + header('X-Content-Type-Options: nosniff'); + if (auth_user() === null) { + http_response_code(401); + echo '[]'; + exit; + } + $title = query_str('q', SW_TITLE_MAX); + $exclude = query_str('not', 10); + $out = []; + foreach (topics_similar($title, $exclude === '' ? null : (int) $exclude, 4) as $row) { + $out[] = ['id' => (int) $row['id'], 'title' => (string) $row['title']]; + } + echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function v_setup(array $errors = [], ?array $old = null): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + $old = $old ?? [ + 'eid_mode' => (string) SW::$cfg['eid_mode'], + 'eid_server_url' => (string) SW::$cfg['eid_server_url'], + 'eid_server_cert' => (string) SW::$cfg['eid_server_cert'], + 'eid_server_key' => (string) SW::$cfg['eid_server_key'], + 'eid_client_url' => (string) SW::$cfg['eid_client_url'], + 'authorized_keys_url' => (string) SW::$cfg['authorized_keys_url'], + 'nect_start' => (string) (SW::$cfg['eid_providers']['nect']['start'] ?? ''), + ]; + $html = '

' . e(t('setup.title')) . '

' + . '

' . e(t('setup.checks')) . '

    '; + foreach (setup_ready() as [$key, $ok]) { + $html .= '
  • ' . e(t($key)) . '
  • '; + } + $stable = authorized_count_stable(); + $html .= '
  • ' + . e(t('setup.check_keys', ['n' => num($stable)])) . '
'; + + if ($errors !== []) { + $html .= ''; + } + + $eid = $old['eid_mode'] === 'eid'; + $html .= '
' . csrf_field() + . '
' + . '

' . e(t('setup.path')) . '

' + . '' + . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '' + . '
' + . '
' + . '' + . '
' + . '
' + . '
'; + if ((string) SW::$cfg['testmode_end'] === 'gui') { + $html .= '
' + . '' + . '' . e(t('setup.token_hint')) . '' + . '' + . '
'; + } else { + $html .= '

' . e(t('setup.end_locked')) . '

'; + } + $html .= '
'; + render(t('setup.title'), $html); +} + +function h_setup_check(): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + if (!rate_allow('setup:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/setup'); + } + [$errors, $values] = setup_validate(setup_read_post()); + $errors = array_values(array_filter($errors, static function (string $key): bool { + return $key !== 'setup.err_list_empty'; + })); + if ($errors !== []) { + v_setup($errors, $values); + } + unset($_SESSION['setup_check']); + if ($values['eid_mode'] === 'eid') { + if ($values['eid_server_url'] === '') { + flash('info', 'flash.setup_no_server'); + v_setup([], $values); + } + $ok = setup_probe($values); + flash($ok ? 'success' : 'error', $ok ? 'flash.setup_ok' : 'flash.setup_failed'); + } else { + $count = setup_probe_list((string) $values['authorized_keys_url']); + $ok = $count !== null && $count > 0; + if ($count === null) { + flash('error', 'flash.setup_list_failed'); + } elseif ($count === 0) { + flash('error', 'setup.err_list_empty'); + } else { + flash('success', 'flash.setup_list_ok', ['n' => num($count)]); + } + } + if ($ok) { + $_SESSION['setup_check'] = setup_check_stamp($values); + } + v_setup([], $values); +} + +function h_setup_finish(): void +{ + require_user(); + if (!test_mode()) { + redirect('/'); + } + if ((string) SW::$cfg['testmode_end'] !== 'gui') { + redirect('/setup'); + } + if (!rate_allow('setup:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/setup'); + } + [$errors, $values] = setup_validate(setup_read_post()); + if (post_str('confirm', 10) !== 'yes') { + $errors[] = 'flash.testmode_confirm'; + } + $token = setup_token(); + if ($token === '' || !hash_equals($token, post_str('token', 80))) { + log_line('SECURITY', 'setup_token_bad', []); + $errors[] = 'setup.err_token'; + } + if (!setup_checked($values)) { + $errors[] = 'setup.err_check_first'; + } + if ($errors !== []) { + v_setup(array_values(array_unique($errors)), $values); + } + if (!setup_save($values)) { + v_setup(['setup.err_save'], $values); + } + setup_apply($values); + test_mode_end(); + unset($_SESSION['setup_check']); + @unlink(setup_token_file()); + auth_logout(); + card_forget(); + flash('info', 'flash.testmode_ended'); + redirect('/auth'); +} + +function v_auth(): void +{ + if (auth_user() !== null) { + redirect('/me'); + } + + $pictogram = ''; + $mode = (string) SW::$cfg['eid_mode']; + $card = card_load(); + $ready = $mode !== 'eid' && $card !== null && authorized_contains(card_identity($card)); + + $html = '
' . $pictogram + . '

' . e(t('auth.title')) . '

'; + + if (test_mode()) { + + $html .= '
' . csrf_field() + . '
'; + } else { + + $html .= '
'; + foreach ((array) SW::$cfg['eid_providers'] as $key => $prov) { + $html .= '' + . e(t('auth.with', ['app' => (string) $prov['label']])) . ''; + } + $html .= '
'; + if ($ready) { + + $html .= '
' + . '
' . csrf_field() + . '
' + . ''; + } + } + $html .= '
'; + render(t('auth.title'), $html); +} + +function site_url(string $path = ''): string +{ + $scheme = sw_is_https() ? 'https' : 'http'; + $host = (string) ($_SERVER['HTTP_HOST'] ?? SW::$cfg['domain']); + return $scheme . '://' . $host . base_path() . $path; +} + +function h_eid_start(): void +{ + $key = query_str('provider', 30); + $providers = (array) SW::$cfg['eid_providers']; + if (!isset($providers[$key])) { + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + if (!rate_allow('eid:' . ip_key(), 20, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/auth'); + } + if ($key === 'ausweisapp') { + + $nonce = bin2hex(random_bytes(16)); + eid_flow_start($nonce); + $tcToken = site_url('/eid/tctoken?s=' . $nonce); + $client = (string) SW::$cfg['eid_client_url']; + log_line('SECURITY', 'eid_client_activation', ['provider' => 'ausweisapp']); + header('Location: ' . $client . '?tcTokenURL=' . rawurlencode($tcToken), true, 303); + exit; + } + $start = (string) ($providers[$key]['start'] ?? ''); + if ($start === '' || preg_match('#^https://#', $start) !== 1) { + + flash('error', 'flash.eid_provider_off'); + redirect('/auth'); + } + + $sep = strpos($start, '?') === false ? '?' : '&'; + header('Location: ' . $start . $sep . 'redirect=' . rawurlencode(site_url('/eid/callback')), true, 303); + exit; +} + +function h_eid_tctoken(): void +{ + header('Content-Type: text/xml; charset=utf-8'); + header('Cache-Control: no-store'); + $nonce = query_str('s', 40); + $errorUrl = site_url('/eid/callback?e=1'); + $flow = eid_flow_find($nonce); + if ($flow === null) { + echo '' . "\n" + . '' . e($errorUrl) + . ''; + exit; + } + $session = eid_server_useid(); + if ($session === null) { + + log_line('SECURITY', 'eid_tctoken_unconfigured', []); + echo '' . "\n" + . '' . e($errorUrl) + . ''; + exit; + } + eid_flow_bind($nonce, $session['session']); + echo '' . "\n" + . '' + . '' . e($session['paos']) . '' + . '' . e($session['session']) . '' + . '' . e(site_url('/eid/callback')) . '' + . '' . e($errorUrl) . '' + . 'urn:liberty:paos:2006-08' + . ''; + exit; +} + +function eid_flow_start(string $nonce): void +{ + SW::$db->run('DELETE FROM eid_flows WHERE created_at < ?', [Clock::now()->getTimestamp() - 600]); + SW::$db->run( + 'INSERT INTO eid_flows (nonce, session_id, created_at) VALUES (?, ?, ?)', + [$nonce, session_id(), Clock::now()->getTimestamp()] + ); +} + +function eid_flow_find(string $nonce): ?array +{ + if (preg_match('/^[a-f0-9]{32}$/', $nonce) !== 1) { + return null; + } + $row = SW::$db->one('SELECT * FROM eid_flows WHERE nonce = ?', [$nonce]); + if ($row === null || (int) $row['created_at'] < Clock::now()->getTimestamp() - 600) { + return null; + } + return $row; +} + +function eid_flow_bind(string $nonce, string $ref): void +{ + SW::$db->run('UPDATE eid_flows SET eid_ref = ? WHERE nonce = ?', [$ref, $nonce]); +} + +function eid_server_useid(?array $over = null): ?array +{ + $cfg = $over ?? SW::$cfg; + $url = (string) ($cfg['eid_server_url'] ?? ''); + if ($url === '' || preg_match('#^https://#', $url) !== 1) { + return null; + } + $soap = '' + . '' + . '' + . ''; + $ctx = ['http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/xml; charset=utf-8\r\nSOAPAction: \"\"\r\n", + 'content' => $soap, + 'timeout' => 8, + 'ignore_errors' => true, + ], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]; + if ((string) ($cfg['eid_server_cert'] ?? '') !== '') { + $ctx['ssl']['local_cert'] = (string) $cfg['eid_server_cert']; + } + if ((string) ($cfg['eid_server_key'] ?? '') !== '') { + $ctx['ssl']['local_pk'] = (string) $cfg['eid_server_key']; + } + $xml = @file_get_contents($url, false, stream_context_create($ctx)); + if (!is_string($xml) || $xml === '') { + log_line('SECURITY', 'eid_server_unreachable', []); + return null; + } + $paos = eid_xml_value($xml, 'eCardServerAddress'); + $session = eid_xml_value($xml, 'Session'); + if ($session === '') { + $session = eid_xml_value($xml, 'ID'); + } + if ($paos === '' || $session === '') { + log_line('SECURITY', 'eid_server_bad_response', []); + return null; + } + return ['paos' => $paos, 'session' => $session]; +} + +function eid_xml_value(string $xml, string $name): string +{ + $pattern = '#<(?:[A-Za-z0-9_.-]+:)?' . preg_quote($name, '#') . '(?:\s[^>]*)?>([^<]*)one( + 'SELECT * FROM eid_flows WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', + [session_id()] + ); + SW::$db->run('DELETE FROM eid_flows WHERE session_id = ?', [session_id()]); + if ($flow === null || ($flow['eid_ref'] ?? '') === '' || query_str('e', 4) !== '') { + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + + log_line('SECURITY', 'eid_callback_unverified', []); + flash('error', 'flash.eid_required'); + redirect('/auth'); +} + +function v_start(): void +{ + http_response_code(200); + $banner = empty(SW::$cfg['show_test_banner']) + ? '' + : '
' . e(SW_DE['banner.test']) . ' / ' . e(SW_EN['banner.test']) . '
'; + echo '' + . '' + . '' + . '' . e((string) SW::$cfg['app_name']) . '' + . '' + . icon_links() + . '' . $banner + . '
' + . '

' . e((string) SW::$cfg['app_name']) . '

' + . '
' + . '
' . csrf_field() + . '' + . '
' + . '
' . csrf_field() + . '' + . '
' + . '
'; + exit; +} + +function v_error_404(): void +{ + $html = '

' . e(t('error.not_found_title')) . '

' + . '

' . e(t('error.not_found')) . '

' + . '

' . e(t('common.back_home')) . '

'; + render(t('error.not_found_title'), $html, 404); +} + +function v_error(int $status, string $messageKey): void +{ + $html = '

' . e(t('error.generic_title')) . '

' + . '

' . e(t($messageKey)) . '

' + . '

' . e(t('common.back_home')) . '

'; + render(t('error.generic_title'), $html, $status); +} + +function v_static(string $titleKey, array $paraKeys): void +{ + $html = '

' . e(t($titleKey)) . '

'; + foreach ($paraKeys as $key) { + $html .= '

' . nl2br(e(t($key)), false) . '

'; + } + $html .= '
'; + render(t($titleKey), $html); +} + +function yq(string $v): string +{ + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $v) . '"'; +} + +function profile_yaml(array $user): string +{ + $userId = (int) $user['id']; + $y = "buergerabstimmung_profil:\n"; + $y .= " oeffentlicher_schluessel: " . yq((string) $user['pseudonym_hash']) . "\n"; + $duty = jury_pending_for($userId); + $upcoming = $duty === null ? jury_upcoming_for($userId) : null; + $y .= " jury_aufgabe: " . yq($duty !== null ? 'offen' : ($upcoming !== null ? 'ausgelost' : 'keine')) . "\n"; + $y .= " stimmen:\n"; + $voted = topics_voted_by($userId); + if ($voted === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($voted as $row) { + $y .= " - thema: " . (int) $row['id'] . "\n"; + $y .= " titel: " . yq((string) $row['title']) . "\n"; + $y .= " stimme: " . yq($row['my_choice'] === 'for' ? 'dafuer' : 'dagegen') . "\n"; + } + $y .= " eigene_themen:\n"; + $authored = topics_by_author($userId); + if ($authored === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($authored as $row) { + $y .= " - thema: " . (int) $row['id'] . "\n"; + $y .= " titel: " . yq((string) $row['title']) . "\n"; + $y .= " status: " . yq((string) $row['status']) . "\n"; + } + $y .= " favoriten:\n"; + $favorites = fav_list($userId); + if ($favorites === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($favorites as $favorite) { + $art = ['category' => 'kategorie', 'topic' => 'thema'][$favorite['kind']] ?? 'gebiet'; + $y .= " - art: " . yq($art) . "\n"; + $y .= " wert: " . yq((string) $favorite['ref']) . "\n"; + } + $y .= " meldungen:\n"; + $reports = reports_by($userId); + if ($reports === []) { + $y = substr($y, 0, -1) . " []\n"; + } + foreach ($reports as $report) { + $y .= " - thema: " . (int) $report['topic_id'] . "\n"; + $y .= " status: " . yq((string) $report['status']) . "\n"; + } + return $y; +} + +function server_sign_pk_hex(): string +{ + if (!card_supports_sodium() || SW::$serverSign === '') { + return ''; + } + return bin2hex(sodium_crypto_sign_publickey_from_secretkey(SW::$serverSign)); +} + +function profile_sealed(array $user): string +{ + $plain = profile_yaml($user); + $pkHex = (string) $user['pseudonym_hash']; + if (!card_supports_sodium() || SW::$serverSign === '' || strlen(@hex2bin($pkHex) ?: '') !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + + $sig = sw_hmac('profil|' . $plain); + return $plain . "# integritaet(hmac-sha256): " . $sig . "\n"; + } + $edPk = hex2bin($pkHex); + $curvePk = sodium_crypto_sign_ed25519_pk_to_curve25519($edPk); + $cipher = sodium_crypto_box_seal($plain, $curvePk); + $sig = sodium_crypto_sign_detached($cipher, SW::$serverSign); + $out = "buergerabstimmung_versiegeltes_profil:\n"; + $out .= " hinweis: " . yq('An oeffentlichen Ausweis-Schluessel verschluesselt; nur mit dem Ausweis lesbar.') . "\n"; + $out .= " verschluesselt_fuer: " . yq($pkHex) . "\n"; + $out .= " server_schluessel: " . yq(server_sign_pk_hex()) . "\n"; + $out .= " chiffre_b64: " . yq(base64_encode($cipher)) . "\n"; + $out .= " server_signatur_b64: " . yq(base64_encode($sig)) . "\n"; + return $out; +} + +function v_jury(): void +{ + $user = require_user(); + $userId = (int) $user['id']; + $duty = jury_pending_for($userId); + $upcoming = $duty === null ? jury_upcoming_for($userId) : null; + + $html = '

' . e(t('jury.title')) . '

'; + if ($duty === null && $upcoming === null) { + $html .= '

' . e(t('jury.none')) . '

' + . '

' . e(t('nav.topics')) . '

'; + render(t('jury.title'), $html); + } + if ($duty === null && $upcoming !== null) { + $html .= '
' . e(t('jury.upcoming', ['date' => Clock::displayLocal((string) $upcoming['voting_starts_at'], t('common.date_format'))])) . '
' + . '

' . e(t('nav.topics')) . '

'; + render(t('jury.title'), $html); + } + + $law = SW_LAWS[(string) $duty['criteria']] ?? null; + $tally = jury_tally((int) $duty['id']); + $deadline = jury_deadline($duty); + + $html .= '

' . e(t('jury.intro')) . '

' + . '
' . e(t('jury.blocked')) . '
' + . '

' . e(t('jury.reported')) . '

' + . '

' . e((string) $duty['title']) . '

' + . '

' . e(t('topic.goal_label')) . '

' . nl2br(e((string) $duty['goal'])) . '

' + . '

' . e(t('topic.reasoning_label')) . '

' . nl2br(e((string) $duty['reasoning'])) . '

' + . '

' . e(t('jury.law')) . '

'; + if ($law !== null) { + $html .= '

' . e($law['norm']) . ' – ' . e($law['titel']) . '

' + . '

„' . e($law['text']) . '“

'; + } else { + $html .= '

' . e((string) $duty['criteria']) . '

'; + } + $html .= '

' . e(t('jury.question')) . '

' + . '
' . csrf_field() + . '' + . '' + . '' + . '' + . '
' + . '

' . e(t('jury.stats', [ + 'cast' => num($tally['cast']), + 'seats' => num($tally['seats']), + 'quorum' => num(min((int) $duty['quorum'], $tally['seats'])), + ])) . '

' + . '

' . e(t('jury.deadline', ['date' => Clock::displayLocal($deadline, t('common.datetime_format'))])) . '

' + . '
'; + render(t('jury.title'), $html); +} + +function v_report(int $topicId): void +{ + require_user(); + $topic = topic_find($topicId); + if ($topic === null || $topic['status'] !== 'active') { + v_error_404(); + } + if (report_open_for($topicId) !== null) { + flash('info', 'flash.report_already_open'); + redirect('/topic/' . $topicId); + } + $q = query_str('q', 80); + $hits = law_search($q); + $html = '

' . e(t('report.title')) . '

' + . '

' . e(t('report.intro')) . '

' + . '

' . e(t('jury.reported')) . '

' + . '

' . e((string) $topic['title']) . '

' + . '
' + . '' + . '
' + . '
'; + if ($q !== '' && $hits === []) { + $html .= '

' . e(t('report.none_found')) . '

'; + } + if ($hits !== []) { + $html .= '
' . csrf_field() + . '' + . '
' . e(t('report.pick')) . ''; + foreach ($hits as $id => $law) { + $html .= ''; + } + $html .= '
' + . '

' . e(t('report.process')) . '

' + . '
' + . '' . e(t('report.cancel')) . '
' + . '
'; + } + render(t('report.title'), $html); +} + +function safe_return(string $fallback): string +{ + $return = post_str('return', 200); + if (preg_match('#^/(topics(\?[A-Za-z0-9=&%._\-]*)?|topic/\d{1,10}|topics/new|me|jury|auth|imprint|privacy)?$#', $return) === 1) { + return $return === '' ? '/' : $return; + } + return $fallback; +} + +function h_tap(): void +{ + if (!rate_allow('auth:' . ip_key(), 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/auth'); + } + if (test_mode()) { + + $card = card_create(); + authorized_add([card_identity($card)], 'test-login'); + test_key_add(card_identity($card)); + } else { + + if ((string) SW::$cfg['eid_mode'] === 'eid') { + log_line('SECURITY', 'eid_not_configured', []); + flash('error', 'flash.eid_required'); + redirect('/auth'); + } + + $card = card_load(); + if ($card === null) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + } + + $identity = card_identity($card); + $sealed = card_seal($card, 'login:' . $identity); + if (!card_open($card['pk'], $sealed, 'login:' . $identity)) { + log_line('SECURITY', 'card_verify_failed', []); + flash('error', 'flash.auth_failed'); + redirect('/auth'); + } + + if (!authorized_contains($identity)) { + log_line('SECURITY', 'card_not_authorized', []); + card_forget(); + flash('error', 'flash.card_not_authorized'); + redirect('/auth'); + } + auth_login($identity); + $_SESSION['auth_slot'] = time_slot(); + redirect('/'); +} + +function h_claim(string $handle): void +{ + if ((string) SW::$cfg['eid_mode'] === 'eid') { + redirect('/auth'); + } + if (preg_match('/^[a-f0-9]{16,64}$/', $handle) !== 1) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $file = SW::$dataDir . '/issued/' . $handle . '.key'; + if (!is_file($file)) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $secret = base64_decode(trim((string) file_get_contents($file)), true); + if ($secret === false) { + flash('error', 'flash.no_card'); + redirect('/auth'); + } + $_SESSION['card'] = base64_encode($secret); + flash('info', 'flash.card_ready'); + redirect('/auth'); +} + +function h_card_new(): void +{ + auth_logout(); + card_forget(); + flash('info', 'flash.card_new'); + redirect('/auth'); +} + +function h_logout(): void +{ + auth_logout(); + flash('info', 'flash.logged_out'); + redirect('/'); +} + +function h_lang(): void +{ + $lang = post_str('lang', 5); + if (!in_array($lang, (array) SW::$cfg['langs'], true)) { + redirect('/'); + } + $_SESSION['lang'] = $lang; + redirect(safe_return('/')); +} + +function h_vote(): void +{ + $user = require_user(); + require_card($user); + $topicId = post_int('topic_id'); + $choice = post_str('choice', 10); + if ($topicId === null) { + redirect('/topics'); + } + $back = '/topic/' . $topicId; + if (!rate_allow('vote:' . (int) $user['id'], 60, 600)) { + flash('error', 'flash.rate_limited'); + redirect($back); + } + try { + vote_cast((int) $user['id'], $topicId, $choice); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect($back); + } + flash('success', $choice === 'none' ? 'flash.vote_withdrawn' : 'flash.vote_saved'); + redirect($back); +} + +function h_topic_create(): void +{ + $user = require_user(); + require_card($user); + $userId = (int) $user['id']; + if (!rate_allow('topic-form:' . $userId, 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/topics/new'); + } + [$errors, $old, $scope, $end] = topic_form_read(); + if ($errors !== []) { + v_main($errors, $old); + } + try { + $topicId = topic_create( + $userId, + $old['title'], + $old['goal'], + $old['reasoning'], + (int) $old['category_id'], + $scope[0], + $scope[1], + $end[0], + $end[1], + $end[2] + ); + } catch (DomainException $e) { + v_main([$e->getMessage()], $old); + return; + } + flash('success', 'flash.topic_created'); + redirect('/topic/' . $topicId); +} + +function topic_form_read(): array +{ + $old = [ + 'title' => post_str('title', SW_TITLE_MAX), + 'goal' => post_str('goal', SW_GOAL_MAX, true), + 'reasoning' => post_str('reasoning', SW_REASONING_MAX, true), + 'category_id' => post_int('category_id') ?? 0, + 'scope' => post_str('scope', 160), + 'end_by_date' => isset($_POST['end_by_date']), + 'end_date' => post_str('end_date', 10), + 'end_by_target' => isset($_POST['end_by_target']), + 'end_value' => post_str('end_value', 10), + 'end_unit' => post_str('end_unit', 10), + ]; + $errors = []; + if (mb_strlen($old['title']) < SW_TITLE_MIN) { + $errors[] = 'topic.err_title'; + } + if (mb_strlen($old['goal']) < SW_GOAL_MIN) { + $errors[] = 'topic.err_goal'; + } + if (mb_strlen($old['reasoning']) < SW_REASONING_MIN) { + $errors[] = 'topic.err_reasoning'; + } + $category = $old['category_id'] > 0 + ? SW::$db->one('SELECT id FROM categories WHERE id = ?', [$old['category_id']]) + : null; + if ($category === null) { + $errors[] = 'topic.err_category'; + } + $scope = scope_decode($old['scope']); + if ($scope === null) { + $errors[] = 'topic.err_scope'; + } + $end = parse_topic_end(); + if ($end === null) { + $errors[] = 'topic.err_end'; + } + return [$errors, $old, $scope, $end]; +} + +function h_topic_archive(int $topicId): void +{ + $user = require_user(); + require_card($user); + try { + topic_archive($topicId, (int) $user['id']); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/topic/' . $topicId); + } + flash('success', 'flash.topic_archived'); + redirect('/topic/' . $topicId); +} + +function h_favorite(): void +{ + $user = require_user(); + require_card($user); + $kind = post_str('kind', 10); + $ref = post_str('ref', 100); + $back = safe_return('/topics'); + if (!rate_allow('favorite:' . (int) $user['id'], 60, 600)) { + flash('error', 'flash.rate_limited'); + redirect($back); + } + try { + $added = fav_toggle((int) $user['id'], $kind, $ref); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect($back); + } + flash('success', $added ? 'flash.favorite_added' : 'flash.favorite_removed'); + redirect($back); +} + +function h_report_create(): void +{ + $user = require_user(); + require_card($user); + $topicId = post_int('topic_id'); + if ($topicId === null) { + redirect('/topics'); + } + if (!rate_allow('report-form:' . (int) $user['id'], 10, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/report/' . $topicId); + } + $lawId = post_str('law', 40); + if (!isset(SW_LAWS[$lawId])) { + flash('error', 'flash.report_no_law'); + redirect('/report/' . $topicId); + } + try { + report_create($topicId, (int) $user['id'], $lawId); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/topic/' . $topicId); + } + log_line('SECURITY', 'report_created', ['topic' => $topicId]); + flash('success', 'flash.report_created'); + redirect('/topic/' . $topicId); +} + +function h_jury_vote(): void +{ + $user = require_user(); + require_card($user); + $reportId = post_int('report_id'); + $vote = post_str('vote', 10); + if ($reportId === null) { + redirect('/jury'); + } + if (!rate_allow('jury:' . (int) $user['id'], 30, 600)) { + flash('error', 'flash.rate_limited'); + redirect('/jury'); + } + try { + jury_cast($reportId, (int) $user['id'], $vote); + } catch (DomainException $e) { + flash('error', $e->getMessage()); + redirect('/jury'); + } + flash('success', 'flash.jury_voted'); + redirect('/jury'); +} + +function send_security_headers(): void +{ + header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; " + . "img-src 'self'; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: no-referrer'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()'); + header('Cross-Origin-Opener-Policy: same-origin'); + header('Cross-Origin-Resource-Policy: same-origin'); + header('X-Robots-Tag: noindex'); + if (sw_is_https()) { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } +} + +function web_main(): void +{ + try { + sw_setup(); + } catch (Throwable $e) { + error_log('buergerabstimmung setup: ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: text/html; charset=utf-8'); + $writable = is_writable(__DIR__ . '/data') || (!is_dir(__DIR__ . '/data') && is_writable(__DIR__)); + $hint = $writable + ? 'Bitte prüfen Sie, ob die PHP-Erweiterungen pdo_sqlite und mbstring aktiv sind.' + : 'Bitte machen Sie das Verzeichnis für PHP beschreibbar (per FTP: Rechte 755 oder 775 für den Ordner der index.php setzen) und laden Sie die Seite neu.'; + echo '' + . 'Einrichtung erforderlich' + . '

Fast geschafft

Die Anwendung konnte noch nicht starten.

' . $hint . '

' + . '

Details stehen im Server-Fehlerprotokoll.

'; + exit; + } + + $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'))); + $base = rtrim($scriptDir, '/'); + if ($base !== '' && preg_match('#^(/[A-Za-z0-9._~\-]+)+$#', $base) !== 1) { + $base = ''; + } + SW::$base = $base; + + $rawPath = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/'); + if (preg_match('#(^|/)data(/|$)#', $rawPath) === 1) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + echo "404\n"; + exit; + } + $pathInfo = (string) ($_SERVER['PATH_INFO'] ?? ''); + if ($pathInfo !== '' && $pathInfo[0] === '/') { + $path = $pathInfo; + } else { + $path = $rawPath; + if (SW::$base !== '' && strpos($path, SW::$base) === 0) { + $path = substr($path, strlen(SW::$base)); + } + if (strpos($path, '/index.php') === 0) { + $path = substr($path, strlen('/index.php')); + } + } + SW::$path = $path === '' ? '/' : $path; + $path = SW::$path; + + $method0 = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if (($method0 === 'GET' || $method0 === 'HEAD') && strpos($rawPath, '/index.php') !== false) { + $qs = (string) ($_SERVER['QUERY_STRING'] ?? ''); + header('Location: ' . (base_path() . $path) . ($qs !== '' ? '?' . $qs : ''), true, 301); + exit; + } + + if (preg_match('#^/a/(app\.css|app\.js|icon\.svg)$#', $path, $m) === 1) { + $kindMap = ['app.css' => 'css', 'app.js' => 'js', 'icon.svg' => 'icon']; + serve_asset($kindMap[$m[1]]); + } + if ($path === '/robots.txt') { + header('Content-Type: text/plain; charset=utf-8'); + echo "User-agent: *\nDisallow: /\n"; + exit; + } + + if (preg_match('#^/(favicon\.ico|favicon\.png|apple-touch-icon(?:-precomposed)?\.png|icon-192\.png)$#', $path, $mIcon) === 1) { + $name = $mIcon[1]; + header('Cache-Control: public, max-age=86400'); + header('X-Content-Type-Options: nosniff'); + if ($name === 'favicon.ico') { + header('Content-Type: image/x-icon'); + echo icon_ico(32); + } else { + $size = $name === 'favicon.png' ? 32 : ($name === 'icon-192.png' ? 192 : 180); + header('Content-Type: image/png'); + echo icon_png($size); + } + exit; + } + + if ($path === '/server.pub') { + header('Content-Type: text/plain; charset=utf-8'); + echo server_sign_pk_hex() . "\n"; + exit; + } + + send_security_headers(); + session_boot(); + + $user = auth_user(); + $lang = is_string($_SESSION['lang'] ?? null) ? (string) $_SESSION['lang'] : ''; + if ($lang === '' && !consent_given()) { + $lang = lang_from_browser(); + } + if (!in_array($lang, (array) SW::$cfg['langs'], true)) { + $lang = (string) SW::$cfg['default_lang']; + } + SW::$lang = $lang; + SW::$tActive = $lang === 'en' ? SW_EN : SW_DE; + + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + try { + maintenance_tick_throttled(); + + if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) { + v_error(405, 'error.method'); + } + + $reading = $method === 'GET' || $method === 'HEAD'; + if ($path === '/consent' && $reading) { + consent_set(); + redirect(consent_return()); + } + + $public = $path === '/' || preg_match('#^/topic/\d{1,10}$#', $path) === 1 + || in_array($path, ['/imprint', '/privacy', '/api/topics'], true); + if (!consent_given() && !($reading && $public)) { + v_error(403, 'error.consent'); + } + + $langChosen = !consent_given() || is_string($_SESSION['lang'] ?? null) || $user !== null; + if (!$langChosen && $reading + && !in_array($path, ['/start', '/imprint', '/privacy'], true) + && strpos($path, '/eid/') !== 0 + && strpos($path, '/api/') !== 0) { + redirect('/start'); + } + if ($path === '/start' && $reading) { + if ($langChosen) { + redirect('/'); + } + v_start(); + } + + if ($method === 'POST' && !csrf_ok()) { + log_line('SECURITY', 'csrf_failed', ['path' => $path]); + flash('error', 'flash.csrf'); + redirect('/'); + } + + if ($user !== null && jury_pending_for((int) $user['id']) !== null) { + $gateAllowed = ['/jury', '/jury/vote', '/logout', '/lang', '/imprint', '/privacy']; + if (!in_array($path, $gateAllowed, true)) { + redirect('/jury'); + } + } + + $isGet = $method === 'GET' || $method === 'HEAD'; + + if ($user === null && $isGet + && !in_array($path, ['/', '/auth', '/imprint', '/privacy', '/api/topics', '/api/similar'], true) + && preg_match('#^/topic/\d{1,10}$#', $path) !== 1 + && strpos($path, '/claim/') !== 0 + && strpos($path, '/eid/') !== 0) { + redirect('/auth'); + } + + if ($path === '/' && $isGet) { + v_main(); + } + if (($path === '/topics' || $path === '/topics/new' || $path === '/me') && $isGet) { + redirect('/'); + } + if ($path === '/topics' && $method === 'POST') { + h_topic_create(); + } + if (preg_match('#^/topic/(\d{1,10})$#', $path, $m) === 1 && $isGet) { + v_topic((int) $m[1]); + } + if (preg_match('#^/topic/(\d{1,10})/archive$#', $path, $m) === 1 && $method === 'POST') { + h_topic_archive((int) $m[1]); + } + if ($path === '/vote' && $method === 'POST') { + h_vote(); + } + if ($path === '/favorite' && $method === 'POST') { + h_favorite(); + } + if (preg_match('#^/report/(\d{1,10})$#', $path, $m) === 1 && $isGet) { + v_report((int) $m[1]); + } + if ($path === '/report' && $method === 'POST') { + h_report_create(); + } + if ($path === '/jury' && $isGet) { + v_jury(); + } + if ($path === '/jury/vote' && $method === 'POST') { + h_jury_vote(); + } + if ($path === '/profil.yaml' && $isGet) { + $u = require_user(); + header('Content-Type: text/yaml; charset=utf-8'); + header('Content-Disposition: attachment; filename="profil.yaml"'); + echo profile_sealed($u); + exit; + } + + if ($path === '/lang' && $method === 'POST') { + h_lang(); + } + if ($path === '/auth' && $isGet) { + v_auth(); + } + if (preg_match('#^/claim/([a-f0-9]{16,64})$#', $path, $m) === 1 && $isGet) { + h_claim($m[1]); + } + if ($path === '/eid/start' && $isGet) { + h_eid_start(); + } + if ($path === '/eid/tctoken' && $isGet) { + h_eid_tctoken(); + } + if ($path === '/eid/callback' && $isGet) { + h_eid_callback(); + } + if ($path === '/tap' && $method === 'POST') { + h_tap(); + } + if ($path === '/card/new' && $method === 'POST') { + h_card_new(); + } + if ($path === '/logout' && $method === 'POST') { + h_logout(); + } + if ($path === '/api/topics' && $isGet) { + h_api_topics(); + } + if ($path === '/api/similar' && $isGet) { + h_api_similar(); + } + if ($path === '/setup' && $isGet) { + v_setup(); + } + if ($path === '/setup/check' && $method === 'POST') { + h_setup_check(); + } + if ($path === '/setup/finish' && $method === 'POST') { + h_setup_finish(); + } + if ($path === '/imprint' && $isGet) { + v_static('imprint.h', ['imprint.p1', 'imprint.p2', 'imprint.p3']); + } + if ($path === '/privacy' && $isGet) { + v_static('privacy.h', ['privacy.p1', 'privacy.p2', 'privacy.p3', 'privacy.p4', 'privacy.p5', + 'privacy.p6', 'privacy.p7', 'privacy.p8', 'privacy.p9', 'privacy.p10']); + } + v_error_404(); + } catch (Throwable $e) { + log_line('ERROR', 'unhandled', ['type' => get_class($e), 'msg' => $e->getMessage(), 'path' => $path]); + v_error(500, 'error.generic'); + } +} + +function cli_switch_db(string $tmpDir, string $name): void +{ + putenv('BUERGERABSTIMMUNG_DB=' . $tmpDir . '/' . $name . '.sqlite'); + SW::$db = new Db($tmpDir . '/' . $name . '.sqlite'); + SW::$db->migrate(); + sw_seed_categories(); +} + +function cli_add_users(int $count, string $prefix): array +{ + $ids = []; + for ($i = 1; $i <= $count; $i++) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, created_at) VALUES (?, ?, ?)', + [$prefix . '-' . $i, 'de', Clock::nowStr()] + ); + $ids[] = SW::$db->lastId(); + } + return $ids; +} + +function cli_make_topic(int $authorId, string $title): int +{ + $categoryId = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $endDate = substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10); + return topic_create($authorId, $title, 'Ein Ziel für den Selbsttest dieses Themas.', 'Eine Begründung für den Selbsttest dieses Themas.', $categoryId, 'bund', null, 'date', $endDate, null); +} + +function cli_selftest(): int +{ + $tmpDir = sys_get_temp_dir() . '/buergerabstimmung-selftest-' . bin2hex(random_bytes(4)); + mkdir($tmpDir, 0700, true); + $pass = 0; + $fail = 0; + $check = static function (string $description, bool $ok, string $note = '') use (&$pass, &$fail): void { + if ($ok) { + $pass++; + echo " ok {$description}\n"; + } else { + $fail++; + echo "FAIL {$description}" . ($note !== '' ? ': ' . $note : '') . "\n"; + } + }; + SW::$pepper = bin2hex(random_bytes(32)); + SW::$dataDir = $tmpDir; + if (card_supports_sodium()) { + SW::$serverSign = sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair()); + } + $t0 = new DateTimeImmutable('2026-03-02 12:00:00', new DateTimeZone('Europe/Berlin')); + Clock::setTestNow($t0); + $warp = static function (string $modify) use (&$t0): void { + $t0 = $t0->modify($modify); + Clock::setTestNow($t0); + }; + + echo "== Grunddaten ==\n"; + cli_switch_db($tmpDir, 'base'); + $check('Kategorien angelegt', count(categories()) >= 20); + $check('Keine vorbefüllten Themen', site_stats()['topics'] === 0); + $check('System-Konto vorhanden', (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 1') === 1); + + echo "== Ausweis-Schlüssel & Zeitfenster ==\n"; + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $card = ['secret' => sodium_crypto_sign_secretkey($pair), 'pk' => sodium_crypto_sign_publickey($pair)]; + $other = sodium_crypto_sign_keypair(); + $otherPk = sodium_crypto_sign_publickey($other); + } else { + $secret = random_bytes(32); + $card = ['secret' => $secret, 'pk' => hash('sha256', 'pk|' . $secret, true)]; + $otherPk = hash('sha256', 'pk|' . random_bytes(32), true); + } + $sealed = card_seal($card, 'vote'); + $check('Umschlag öffnet mit richtigem öffentlichen Schlüssel', card_open($card['pk'], $sealed, 'vote') === true); + $check('Fremder öffentlicher Schlüssel wird abgelehnt', card_open($otherPk, $sealed, 'vote') === false); + $check('Falsche Aktion wird abgelehnt', card_open($card['pk'], $sealed, 'report') === false); + $tSave = $t0; + $warp('+11 minutes'); + $check('Alter Umschlag verfällt (TOTP-Zeitfenster)', card_open($card['pk'], $sealed, 'vote') === false); + $check('Neuer Umschlag zu neuer Zeit ist anders und gültig', + card_seal($card, 'vote') !== $sealed && card_open($card['pk'], card_seal($card, 'vote'), 'vote') === true); + $t0 = $tSave; + Clock::setTestNow($t0); + $check('Identität = öffentlicher Schlüssel (kein Pseudonym)', card_identity($card) === bin2hex($card['pk'])); + + echo "== Stimmen entkoppelt & 24h-Sperre ==\n"; + $vt1 = cli_add_users(1, 'vt')[0]; + $vtTopic = cli_make_topic($vt1, 'Thema für Stimmtests'); + vote_cast($vt1, $vtTopic, 'for'); + $check('Stimmen-Tabelle ohne Ausweis-Bezug (kein user_id)', + SW::$db->val("SELECT COUNT(*) FROM pragma_table_info('votes') WHERE name = 'user_id'") == 0); + $tag = vote_tag($vtTopic, user_pk($vt1)); + $check('Stimme unter HMAC-Marker gespeichert (nicht Klar-ID)', + SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ? AND voter_tag = ?', [$vtTopic, $tag]) == 1); + vote_cast($vt1, $vtTopic, 'against'); + $check('Änderung innerhalb 24 h möglich', topic_user_vote($vtTopic, $vt1) === 'against'); + $warp('+25 hours'); + try { + vote_cast($vt1, $vtTopic, 'for'); + $check('Änderung nach 24 h gesperrt', false); + } catch (DomainException $e) { + $check('Änderung nach 24 h gesperrt', $e->getMessage() === 'flash.vote_locked'); + } + $warp('-25 hours'); + + echo "== Themen-Ende (Datum/Anzahl) & Autor-Rechte ==\n"; + $au = cli_add_users(1, 'au')[0]; + $catId = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $countTopic = topic_create($au, 'Thema endet bei 2 Stimmen', 'Ziel für den Anzahl-Test hier.', 'Begründung für den Anzahl-Test hier.', $catId, 'bund', null, 'count', null, 2); + $c1 = cli_add_users(1, 'c1')[0]; + $c2 = cli_add_users(1, 'c2')[0]; + vote_cast($c1, $countTopic, 'for'); + $check('Thema bei Zielzahl noch offen (1/2)', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$countTopic]) === 'active'); + vote_cast($c2, $countTopic, 'against'); + $check('Thema schließt bei Erreichen der Zielzahl (2/2)', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$countTopic]) === 'closed'); + try { + vote_cast($au, $countTopic, 'for'); + $check('Keine Stimme nach Schließung', false); + } catch (DomainException $e) { + $check('Keine Stimme nach Schließung', $e->getMessage() === 'flash.topic_not_votable'); + } + $warp('+1 day'); + $dateTopic = topic_create($au, 'Thema endet gestern', 'Ziel für den Datum-Test hier.', 'Begründung für den Datum-Test hier.', $catId, 'bund', null, 'date', substr(Clock::nowStr(), 0, 10), null); + $warp('+2 days'); + maintenance_tick(); + $check('Datumsende schließt Thema', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$dateTopic]) === 'closed'); + $warp('-3 days'); + $au2 = cli_add_users(1, 'au2')[0]; + $ownTopic = cli_make_topic($au2, 'Uferpromenade in Buchholz sanieren'); + $check('Kein Bearbeiten mehr vorhanden', + !function_exists('topic_update') && !function_exists('v_topic_edit') && !function_exists('h_topic_edit')); + try { + topic_archive($ownTopic, $c1); + $check('Nicht-Autor kann nicht archivieren', false); + } catch (DomainException $e) { + $check('Nicht-Autor kann nicht archivieren', $e->getMessage() === 'flash.not_author'); + } + vote_cast($c1, $ownTopic, 'for'); + try { + topic_archive($ownTopic, $au2); + $check('Abgestimmtes Thema bleibt dauerhaft bestehen', false); + } catch (DomainException $e) { + $check('Abgestimmtes Thema bleibt dauerhaft bestehen', $e->getMessage() === 'flash.topic_locked'); + } + $check('Abgestimmtes Thema weiterhin aktiv', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$ownTopic]) === 'active'); + $warp('+1 day'); + $freshTopic = cli_make_topic($au2, 'Thema ohne Stimmen zum Archivieren'); + topic_archive($freshTopic, $au2); + $check('Thema ohne Stimmen wird archiviert, nicht gelöscht', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$freshTopic]) === 'archived' + && SW::$db->val('SELECT COUNT(*) FROM topics WHERE id = ?', [$freshTopic]) == 1); + $check('Archiviertes Thema erscheint nicht in der Liste', + !in_array($freshTopic, array_map(static function (array $r): int { + return (int) $r['id']; + }, topics_list([], 1, 50, null)['rows']), true)); + try { + vote_cast($c1, $freshTopic, 'for'); + $check('Archiviertes Thema ist nicht wählbar', false); + } catch (DomainException $e) { + $check('Archiviertes Thema ist nicht wählbar', $e->getMessage() === 'flash.topic_not_votable'); + } + try { + topic_archive($freshTopic, $au2); + $check('Archiviertes Thema kann nicht erneut archiviert werden', false); + } catch (DomainException $e) { + $check('Archiviertes Thema kann nicht erneut archiviert werden', $e->getMessage() === 'flash.not_author'); + } + $warp('-1 day'); + $similar = topics_similar('Uferpromenade in Buchholz sanieren', null, 5); + $check('Ähnliches Thema wird gefunden', $similar !== [] && (int) $similar[0]['id'] === $ownTopic); + $check('Unähnlicher Titel liefert nichts', + topics_similar('Vollkommen anderes Anliegen ohne Bezug', null, 5) === []); + $check('Eigenes Thema wird bei der Suche ausgeblendet', + topics_similar('Uferpromenade in Buchholz sanieren', $ownTopic, 5) === []); + + echo "== Profil: verschlüsselt an Public Key + Server-Signatur ==\n"; + if (card_supports_sodium()) { + $pair = sodium_crypto_sign_keypair(); + $pkHex = bin2hex(sodium_crypto_sign_publickey($pair)); + SW::$db->run('INSERT INTO users (pseudonym_hash, lang, created_at) VALUES (?, ?, ?)', [$pkHex, 'de', Clock::nowStr()]); + $pu = SW::$db->one('SELECT * FROM users WHERE pseudonym_hash = ?', [$pkHex]); + $sealed = profile_sealed($pu); + $check('Ausgeliefertes Profil enthält keinen Klartext-Schlüssel im Inhalt', + strpos($sealed, 'buergerabstimmung_versiegeltes_profil') === 0); + + preg_match('/chiffre_b64: "([^"]+)"/', $sealed, $cm); + preg_match('/server_signatur_b64: "([^"]+)"/', $sealed, $sm); + $cipher = base64_decode($cm[1]); + $sig = base64_decode($sm[1]); + $serverPk = hex2bin(server_sign_pk_hex()); + $check('Server-Signatur bestätigt (keine Manipulation)', + sodium_crypto_sign_verify_detached($sig, $cipher, $serverPk) === true); + $check('Manipulierte Chiffre fällt bei Signaturprüfung durch', + sodium_crypto_sign_verify_detached($sig, $cipher . 'x', $serverPk) === false); + $curveSk = sodium_crypto_sign_ed25519_sk_to_curve25519(sodium_crypto_sign_secretkey($pair)); + $curvePk = sodium_crypto_sign_ed25519_pk_to_curve25519(sodium_crypto_sign_publickey($pair)); + $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($curveSk, $curvePk); + $plain = sodium_crypto_box_seal_open($cipher, $keypair); + $check('Nur mit dem Ausweis-Schlüssel entschlüsselbar', is_string($plain) && strpos($plain, 'buergerabstimmung_profil') === 0); + } else { + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + $check('Profil-Versiegelung übersprungen (kein sodium)', true); + } + + echo "== Meldegrund: Gesetzesverstoß ==\n"; + $check('Suche nach Schlagwort findet Volksverhetzung', isset(law_search('hetze')['stgb-130-1'])); + $check('Suche nach Paragraph findet § 185', isset(law_search('185')['stgb-185'])); + $check('Leere Suche liefert nichts', law_search('') === []); + try { + report_create(999999, 1, 'kein-gesetz'); + $check('Unbekanntes Gesetz abgelehnt', false); + } catch (DomainException $e) { + $check('Unbekanntes Gesetz abgelehnt', $e->getMessage() === 'flash.report_no_law'); + } + + echo "== Geltungsbereich (amtliche Auswahl) ==\n"; + $check('Deutschland', scope_decode('de') === ['bund', null]); + $check('Bundesland', scope_decode('bl:Bayern') === ['bundesland', 'Bayern']); + $check('Landkreis', scope_decode('kr:Bayern:Landkreis München') === ['landkreis', 'Landkreis München']); + $check('Unbekanntes Gebiet abgelehnt', scope_decode('kr:Bayern:Atlantis') === null && scope_decode('bl:Atlantis') === null); + $check('Gebietsliste vollständig geladen', count(SW_REGIONS) === 16 && array_sum(array_map('count', SW_REGIONS)) > 350); + + echo "== Themen: 1 pro Tag ==\n"; + $alice = cli_add_users(1, 'alice')[0]; + $topicId = cli_make_topic($alice, 'Testthema Nummer eins'); + $check('Erstes Thema angelegt', $topicId > 0); + try { + cli_make_topic($alice, 'Zweites Thema am selben Tag'); + $check('Zweites Thema am selben Tag abgelehnt', false); + } catch (DomainException $e) { + $check('Zweites Thema am selben Tag abgelehnt', $e->getMessage() === 'flash.topic_daily_limit'); + } + $warp('+1 day'); + $check('Thema am Folgetag erlaubt', cli_make_topic($alice, 'Thema am nächsten Tag') > 0); + + echo "== Stimmen & Favoriten ==\n"; + $bob = cli_add_users(1, 'bob')[0]; + vote_cast($bob, $topicId, 'for'); + $check('Stimme dafür gespeichert', topic_user_vote($topicId, $bob) === 'for'); + vote_cast($bob, $topicId, 'against'); + $check('Stimme änderbar', topic_user_vote($topicId, $bob) === 'against'); + vote_cast($bob, $topicId, 'none'); + $check('Stimme zurückziehbar (neutral = keine Stimme)', topic_user_vote($topicId, $bob) === null); + $slug = (string) SW::$db->val('SELECT slug FROM categories ORDER BY id LIMIT 1'); + $check('Favorit angelegt', fav_toggle($bob, 'category', $slug) === true); + $check('Favorit entfernt', fav_toggle($bob, 'category', $slug) === false); + $check('Gebiets-Favorit (Landkreis) gegen Liste geprüft', fav_toggle($bob, 'scope', 'landkreis:Ostalbkreis') === true); + try { + fav_toggle($bob, 'scope', 'landkreis:Entenhausen'); + $check('Erfundenes Gebiet abgelehnt', false); + } catch (DomainException $e) { + $check('Erfundenes Gebiet abgelehnt', true); + } + + echo "== Jury-Größe (1 %-Regel) ==\n"; + cli_switch_db($tmpDir, 'big'); + $crowd = cli_add_users(600, 'crowd'); + $reporter600 = cli_add_users(1, 'rep')[0]; + $target = cli_make_topic($crowd[0], 'Zielthema für die große Jury'); + report_create($target, $reporter600, 'stgb-130-1'); + $bigReport = SW::$db->one('SELECT * FROM reports ORDER BY id DESC LIMIT 1'); + $check('Jury = 1 % bei 601 Nutzenden (aufgerundet)', (int) $bigReport['jury_size'] === (int) ceil(601 * 0.01)); + $check('Quorum = 0,5 % (mind. 3)', (int) $bigReport['quorum'] === max(3, (int) ceil(601 * 0.005))); + + echo "== Meldung & Jury: Ausschlüsse, Fristen, Karenz ==\n"; + cli_switch_db($tmpDir, 'jury'); + $users = cli_add_users(12, 'u'); + $tX = cli_make_topic($users[8], 'Gemeldetes Thema X'); + $tY = cli_make_topic($users[9], 'Gemeldetes Thema Y'); + $jurorsOf = static function (int $reportId): array { + return array_map(static function (array $r): int { + return (int) $r['user_id']; + }, SW::$db->all('SELECT user_id FROM report_jurors WHERE report_id = ?', [$reportId])); + }; + + $r1 = report_create($tX, $users[0], 'stgb-86a-1'); + $j1 = $jurorsOf($r1); + $check('Jury 1: 5 Sitze (Mindestgröße)', count($j1) === 5); + $check('Melder und Autor nicht in der Jury', !in_array($users[0], $j1, true) && !in_array($users[8], $j1, true)); + $r1Row = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$r1]); + $check('Meldung wartet bis Mitternacht', $r1Row['status'] === 'pending'); + $check('Start zur nächsten Mitternacht (00:00 lokal)', $r1Row['voting_starts_at'] === Clock::nextLocalMidnightUtcStr()); + try { + report_create($tX, $users[1], 'stgb-185'); + $check('Zweite Meldung zum selben Thema abgelehnt', false); + } catch (DomainException $e) { + $check('Zweite Meldung zum selben Thema abgelehnt', $e->getMessage() === 'flash.report_already_open'); + } + $r2 = report_create($tY, $users[1], 'stgb-241-1'); + $j2 = $jurorsOf($r2); + $check('Jury 2 disjunkt zu laufender Jury 1', array_intersect($j1, $j2) === []); + $check('Kein Jury-Gate vor Abstimmungsstart', jury_pending_for($j1[0]) === null); + + $warp('+1 day'); + maintenance_tick(); + $check('Abstimmung um 00:00 gestartet', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r1]) === 'voting'); + $check('Jury-Gate nach Start aktiv', jury_pending_for($j1[0]) !== null); + jury_cast($r1, $j1[0], 'confirm'); + jury_cast($r1, $j1[1], 'confirm'); + jury_cast($r1, $j1[2], 'neutral'); + maintenance_tick(); + $check('Keine Entscheidung vor Ablauf der 24 h', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r1]) === 'voting'); + try { + jury_cast($r1, $j1[0], 'reject'); + $check('Doppelte Jury-Stimme abgelehnt', false); + } catch (DomainException $e) { + $check('Doppelte Jury-Stimme abgelehnt', $e->getMessage() === 'flash.jury_already_voted'); + } + $warp('+25 hours'); + maintenance_tick(); + $r1Row = SW::$db->one('SELECT * FROM reports WHERE id = ?', [$r1]); + $check('Entscheidung nach Frist + Quorum (2:0 bestätigt)', $r1Row['status'] === 'decided_removed'); + $check('Thema entfernt', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$tX]) === 'removed'); + $expectedCooldown = Clock::addDaysStr((string) $r1Row['decided_at'], 3); + $cooldowns = SW::$db->all( + 'SELECT jury_cooldown_until FROM users WHERE id IN (' . implode(',', array_fill(0, count($j1), '?')) . ')', + $j1 + ); + $check('Karenz (3 Tage) für alle Jury-Mitglieder gesetzt', array_unique(array_column($cooldowns, 'jury_cooldown_until')) === [$expectedCooldown]); + + $tZ = cli_make_topic($j1[0], 'Gemeldetes Thema Z'); + $r3 = report_create($tZ, $j2[0], 'stgb-126a-1'); + $j3 = $jurorsOf($r3); + $expectedJ3 = array_values(array_diff(array_map('intval', $users), $j1, $j2)); + sort($j3); + sort($expectedJ3); + $check('Karenz + laufende Jurys schließen korrekt aus (Restmenge = 2)', $j3 === $expectedJ3 && count($j3) === 2); + + $warp('+1 day'); + maintenance_tick(); + jury_cast($r3, $j3[0], 'reject'); + $warp('+25 hours'); + maintenance_tick(); + $check('Meldung läuft weiter, bis das Quorum erreicht ist', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r3]) === 'voting'); + jury_cast($r3, $j3[1], 'reject'); + $check('Entscheidung sofort bei Quorum nach Fristablauf (behalten)', SW::$db->val('SELECT status FROM reports WHERE id = ?', [$r3]) === 'decided_kept'); + $check('Thema bleibt bei Ablehnung bestehen', SW::$db->val('SELECT status FROM topics WHERE id = ?', [$tZ]) === 'active'); + + $warp('+2 days'); + $tW = cli_make_topic($j2[2], 'Gemeldetes Thema W'); + $r4 = report_create($tW, $j2[1], 'stgb-240-1'); + $j4 = $jurorsOf($r4); + sort($j1); + sort($j4); + $check('Nach 3 Tagen Karenz wieder losbar (Jury 4 = frühere Jury 1)', $j4 === $j1); + + echo "== Strenge im Normalmodus / Skip im Testmodus ==\n"; + if (card_supports_sodium()) { + $pairA = sodium_crypto_sign_keypair(); + $cardA = ['secret' => sodium_crypto_sign_secretkey($pairA), 'pk' => sodium_crypto_sign_publickey($pairA)]; + $pairB = sodium_crypto_sign_keypair(); + $cardB = ['secret' => sodium_crypto_sign_secretkey($pairB), 'pk' => sodium_crypto_sign_publickey($pairB)]; + $userA = ['pseudonym_hash' => card_identity($cardA)]; + SW::$testMode = false; + $check('Normalmodus: ohne Ausweis abgelehnt', card_confirm_ok($userA, null, 'confirm:/vote') === false); + $check('Normalmodus: fremder Ausweis abgelehnt', card_confirm_ok($userA, $cardB, 'confirm:/vote') === false); + $check('Normalmodus: eigener Ausweis bestätigt', card_confirm_ok($userA, $cardA, 'confirm:/vote') === true); + SW::$testMode = true; + $check('Testmodus: Ausweis-Aufforderung entfällt', card_confirm_ok($userA, null, 'confirm:/vote') === true); + SW::$testMode = false; + } else { + for ($i = 0; $i < 5; $i++) { + $check('Strenge-Prüfung übersprungen (kein sodium)', true); + } + } + + echo "== Allowlist autorisierter Ausweis-Schlüssel ==\n"; + @unlink(authorized_file()); + $check('Leere Allowlist weist alles ab', authorized_contains(str_repeat('a', 64)) === false); + $goodPk = str_repeat('b', 64); + authorized_add([$goodPk], 'test'); + $check('Autorisierter Schlüssel erkannt', authorized_contains($goodPk) === true); + $check('Nicht autorisierter Schlüssel abgewiesen', authorized_contains(str_repeat('c', 64)) === false); + $check('Doppeltes Hinzufügen ohne Duplikat', authorized_add([$goodPk], 'test') === 0); + $check('Ungültiges Format wird ignoriert', authorized_add(['xyz'], 'test') === 0 && !authorized_contains('xyz')); + + echo "== Sortierung: größte Netto-Zustimmung oben ==\n"; + cli_switch_db($tmpDir, 'sortdb'); + $sa = cli_add_users(1, 'sa')[0]; + $catS = (int) SW::$db->val('SELECT id FROM categories ORDER BY id LIMIT 1'); + $lowNet = topic_create($sa, 'Radweg-Vorschlag mit knapper Mehrheit', 'Ziel des ersten Sortier-Themas hier.', 'Begründung des ersten Sortier-Themas hier.', $catS, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10), null); + $sb = cli_add_users(1, 'sb')[0]; + $highNet = topic_create($sb, 'Radweg-Vorschlag mit klarer Mehrheit', 'Ziel des zweiten Sortier-Themas hier.', 'Begründung des zweiten Sortier-Themas hier.', $catS, 'bund', null, 'date', substr(Clock::addDaysStr(Clock::nowStr(), 30), 0, 10), null); + $sv = cli_add_users(6, 'sv'); + + vote_cast($sv[0], $lowNet, 'for'); + vote_cast($sv[1], $lowNet, 'against'); + + vote_cast($sv[2], $highNet, 'for'); + vote_cast($sv[3], $highNet, 'for'); + vote_cast($sv[4], $highNet, 'for'); + $listed = topics_list(['q' => 'Radweg-Vorschlag'], 1, 10, null); + $check('Suchtreffer nach Netto-Zustimmung: höchste zuerst', + $listed['rows'] !== [] && (int) $listed['rows'][0]['id'] === $highNet); + + echo "== Kontolöschung (DSGVO) ==\n"; + cli_switch_db($tmpDir, 'gdpr'); + $pairIds = cli_add_users(2, 'cd'); + $carol = $pairIds[0]; + $dave = $pairIds[1]; + $carolTopic = cli_make_topic($carol, 'Thema von Carol zum Löschen'); + $daveTopic = cli_make_topic($dave, 'Thema von Dave bleibt bestehen'); + vote_cast($carol, $daveTopic, 'for'); + fav_toggle($carol, 'scope', 'bundesland:Bayern'); + $carolTag = vote_tag($daveTopic, user_pk($carol)); + account_delete($carol); + $check('Nutzer gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE id = ?', [$carol]) === 0); + $check('Stimme bleibt anonym erhalten (nicht mehr zuordenbar)', + (int) SW::$db->val('SELECT COUNT(*) FROM votes WHERE topic_id = ? AND voter_tag = ?', [$daveTopic, $carolTag]) === 1); + $check('Favoriten gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM favorites WHERE user_id = ?', [$carol]) === 0); + $systemId = (int) SW::$db->val('SELECT id FROM users WHERE is_system = 1'); + $check('Thema entkoppelt (System-Konto)', (int) SW::$db->val('SELECT author_id FROM topics WHERE id = ?', [$carolTopic]) === $systemId); + + echo "== Themen-Ende: Datum, Zielwert, Kombination ==\n"; + cli_switch_db($tmpDir, 'endform'); + $readEnd = static function (array $post): ?array { + $_POST = $post; + $result = parse_topic_end(); + $_POST = []; + return $result; + }; + $soon = substr(Clock::addDaysStr(Clock::nowStr(), 5), 0, 10); + $check('Nur Datum ergibt Modus "date"', $readEnd(['end_by_date' => '1', 'end_date' => $soon]) === ['date', $soon, null]); + $check('Nur Stimmenzahl ergibt Modus "count"', $readEnd(['end_by_target' => '1', 'end_value' => '250', 'end_unit' => 'count']) === ['count', null, 250]); + $check('Datum + Zielwert ergibt Modus "both"', + $readEnd(['end_by_date' => '1', 'end_date' => $soon, 'end_by_target' => '1', 'end_value' => '80', 'end_unit' => 'count']) === ['both', $soon, 80]); + $check('Ohne Auswahl kein gültiges Ende', $readEnd([]) === null); + $check('Datum in der Vergangenheit abgelehnt', $readEnd(['end_by_date' => '1', 'end_date' => '2000-01-01']) === null); + $check('Prozent über 100 abgelehnt', $readEnd(['end_by_target' => '1', 'end_value' => '120', 'end_unit' => 'percent']) === null); + cli_add_users(200, 'pc'); + $pct = $readEnd(['end_by_target' => '1', 'end_value' => '10', 'end_unit' => 'percent']); + $check('Prozent wird in Stimmenzahl umgerechnet', $pct !== null && $pct[2] === 20); + $bothUser = cli_add_users(1, 'both')[0]; + $bothTopic = topic_create($bothUser, 'Thema mit Datum und Zielzahl', 'Ziel des Kombi-Tests hier.', 'Begründung des Kombi-Tests hier.', $catId, 'bund', null, 'both', $soon, 2); + $bv = cli_add_users(2, 'bv'); + vote_cast($bv[0], $bothTopic, 'for'); + vote_cast($bv[1], $bothTopic, 'for'); + $check('Kombination: Zielzahl schließt vor dem Datum', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$bothTopic]) === 'closed'); + $dateFirst = topic_create($bv[0], 'Thema schließt am Datum', 'Ziel des Datum-Kombi-Tests.', 'Begründung des Datum-Kombi-Tests.', $catId, 'bund', null, 'both', substr(Clock::nowStr(), 0, 10), 1000000); + $warp('+2 days'); + maintenance_tick(); + $check('Kombination: Datum schließt vor der Zielzahl', + SW::$db->val('SELECT status FROM topics WHERE id = ?', [$dateFirst]) === 'closed'); + $warp('-2 days'); + + echo "== Testbetrieb: Zustand in der Datenbank, Ende löscht alles ==\n"; + cli_switch_db($tmpDir, 'testmode'); + SW::$testMode = null; + $check('Testbetrieb ist im Auslieferungszustand aktiv', test_mode() === true); + $tmUser = cli_add_users(1, 'tm')[0]; + $tmTopic = cli_make_topic($tmUser, 'Thema aus dem Testbetrieb'); + vote_cast(cli_add_users(1, 'tv')[0], $tmTopic, 'for'); + fav_toggle($tmUser, 'scope', 'bundesland:Bayern'); + test_mode_end(); + $check('Testbetrieb beendet', test_mode() === false); + $check('Themen gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM topics') === 0); + $check('Stimmen gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM votes') === 0); + $check('Favoriten gelöscht', (int) SW::$db->val('SELECT COUNT(*) FROM favorites') === 0); + $check('Konten gelöscht (System-Konto bleibt)', + (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 0') === 0 + && (int) SW::$db->val('SELECT COUNT(*) FROM users WHERE is_system = 1') === 1); + $check('Kategorien bleiben erhalten', count(categories()) >= 20); + SW::$testMode = true; + + echo "== Einrichtung des Echtbetriebs ==\n"; + cli_switch_db($tmpDir, 'setup'); + SW::$testMode = null; + @unlink(authorized_file()); + @unlink(test_keys_file()); + $base = ['eid_mode' => 'demo', 'eid_server_url' => '', 'eid_server_cert' => '', 'eid_server_key' => '', + 'eid_client_url' => 'http://127.0.0.1:24727/eID-Client', 'authorized_keys_url' => '', 'nect_start' => '']; + $validate = static function (array $over) use ($base): array { + [$errors] = setup_validate(array_merge($base, $over)); + return $errors; + }; + $check('Leere Freigabeliste ohne Abgleich-Adresse abgelehnt', + in_array('setup.err_list_empty', $validate([]), true)); + authorized_add([str_repeat('ab', 32)], 'selftest'); + $check('Freigabeliste mit Eintrag genügt', $validate([]) === []); + $check('eID-Modus ohne Serveradresse abgelehnt', + in_array('setup.err_server_missing', $validate(['eid_mode' => 'eid']), true)); + $check('Serveradresse ohne https abgelehnt', + in_array('setup.err_https', $validate(['eid_server_url' => 'http://eid.example']), true)); + $check('Nicht lesbares Zertifikat abgelehnt', + in_array('setup.err_file', $validate(['eid_server_cert' => '/kein/pfad/cert.pem']), true)); + $check('Gültige eID-Angaben angenommen', + $validate(['eid_mode' => 'eid', 'eid_server_url' => 'https://eid.example/soap']) === []); + $saved = array_merge($base, ['eid_mode' => 'eid', 'eid_server_url' => 'https://eid.example/soap', + 'nect_start' => 'https://nect.example/start']); + $check('Einstellungen gespeichert', setup_save($saved)); + $loaded = setup_load(); + $check('Einstellungen unverändert gelesen', + $loaded['eid_server_url'] === 'https://eid.example/soap' && $loaded['eid_mode'] === 'eid'); + setup_apply($loaded); + $check('Einstellungen überschreiben die Vorgabe', + (string) SW::$cfg['eid_server_url'] === 'https://eid.example/soap' + && (string) SW::$cfg['eid_providers']['nect']['start'] === 'https://nect.example/start'); + $check('Nicht vorgesehene Schlüssel werden nicht übernommen', + !array_key_exists('session_idle_minutes', setup_load())); + $check('Unerreichbarer eID-Server meldet Fehlschlag', + setup_probe(array_merge($base, ['eid_server_url' => 'https://127.0.0.1:1/soap'])) === false); + $token = setup_token(); + $check('Einrichtungsschlüssel erzeugt und stabil', strlen($token) === 32 && $token === setup_token()); + @unlink(authorized_file()); + @unlink(test_keys_file()); + $realKey = str_repeat('cd', 32); + $testKey = str_repeat('ef', 32); + authorized_add([$realKey], 'issue-card'); + authorized_add([$testKey], 'test-login'); + test_key_add($testKey); + $check('Testschlüssel zählen nicht als freigegebene Ausweise', authorized_count_stable() === 1); + test_mode_end(); + $check('Testschlüssel beim Umschalten entfernt', !authorized_contains($testKey)); + $check('Echter Ausweis-Schlüssel bleibt erhalten', authorized_contains($realKey)); + SW::$cfg = SW_CONFIG; + @unlink(setup_file()); + @unlink(setup_token_file()); + SW::$testMode = true; + + echo "== Sprachtabellen ==\n"; + $dupes = static function (string $const): array { + $src = file_get_contents(__FILE__); + $start = strpos($src, 'const ' . $const . ' = ['); + $end = strpos($src, "\n];", $start); + $body = substr($src, $start, $end - $start); + preg_match_all("/^ {4}'([a-z0-9_.]+)' =>/m", $body, $m); + $seen = []; + $twice = []; + foreach ($m[1] as $key) { + if (isset($seen[$key])) { + $twice[] = $key; + } + $seen[$key] = true; + } + return $twice; + }; + $check('Keine doppelten Schlüssel in SW_DE', $dupes('SW_DE') === []); + $check('Keine doppelten Schlüssel in SW_EN', $dupes('SW_EN') === []); + $outside = static function (): string { + $src = file_get_contents(__FILE__); + $out = ''; + $cursor = 0; + foreach (['SW_DE', 'SW_EN'] as $const) { + $start = strpos($src, 'const ' . $const . ' = ['); + $end = strpos($src, "\n];", $start) + 3; + $out .= substr($src, $cursor, $start - $cursor); + $cursor = $end; + } + return $out . substr($src, $cursor); + }; + $body = $outside(); + $orphans = array_values(array_filter(array_keys(SW_DE), static function (string $key) use ($body): bool { + return strpos($body, "'" . $key . "'") === false; + })); + $check('Keine verwaisten Textschlüssel', $orphans === [], implode(', ', $orphans)); + $missingEn = array_diff(array_keys(SW_DE), array_keys(SW_EN)); + $missingDe = array_diff(array_keys(SW_EN), array_keys(SW_DE)); + $check('Deutsch und Englisch decken dieselben Schlüssel ab', + $missingEn === [] && $missingDe === []); + $emptyVals = array_filter(SW_EN, static function ($v): bool { + return trim((string) $v) === ''; + }); + $check('Keine leeren englischen Texte', $emptyVals === []); + + Clock::setTestNow(null); + putenv('BUERGERABSTIMMUNG_DB'); + array_map('unlink', glob($tmpDir . '/*') ?: []); + rmdir($tmpDir); + printf("\nErgebnis: %d bestanden, %d fehlgeschlagen.\n", $pass, $fail); + return $fail === 0 ? 0 : 1; +} + +function cli_seed(int $count): void +{ + $created = 0; + $votes = 0; + SW::$db->tx(function () use ($count, &$created, &$votes): void { + $now = Clock::nowStr(); + for ($i = 0; $i < $count; $i++) { + SW::$db->run( + 'INSERT INTO users (pseudonym_hash, lang, is_seed, created_at) VALUES (?, ?, 1, ?)', + ['seed-' . bin2hex(random_bytes(28)), 'de', $now] + ); + $created++; + } + $seedIds = array_map(static function (array $r): int { + return (int) $r['id']; + }, SW::$db->all('SELECT id FROM users WHERE is_seed = 1')); + foreach (SW::$db->all("SELECT id FROM topics WHERE status = 'active'") as $topic) { + $turnout = random_int(15, 60); + $forShare = random_int(25, 75); + foreach ($seedIds as $userId) { + if (random_int(1, 100) > $turnout) { + continue; + } + $choice = random_int(1, 100) <= $forShare ? 'for' : 'against'; + + SW::$db->run( + 'INSERT OR IGNORE INTO votes (topic_id, voter_tag, choice, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + [(int) $topic['id'], vote_tag((int) $topic['id'], user_pk($userId)), $choice, $now, $now] + ); + $votes++; + } + } + }); + printf("Demo-Nutzer angelegt: %d, Stimmen erzeugt: %d\n", $created, $votes); +} + +function cli_jurysim(): void +{ + maintenance_tick(); + $seats = SW::$db->all( + "SELECT rj.report_id, rj.user_id + FROM report_jurors rj + JOIN reports r ON r.id = rj.report_id + JOIN users u ON u.id = rj.user_id + WHERE r.status = 'voting' AND rj.vote IS NULL AND u.is_seed = 1" + ); + $cast = 0; + foreach ($seats as $seat) { + if (random_int(1, 100) > 80) { + continue; + } + $roll = random_int(1, 100); + $vote = $roll <= 55 ? 'confirm' : ($roll <= 85 ? 'reject' : 'neutral'); + try { + jury_cast((int) $seat['report_id'], (int) $seat['user_id'], $vote); + $cast++; + } catch (DomainException $e) { + + } + } + printf("Simulierte Jury-Stimmen: %d\n", $cast); +} + +function cli_main(array $argv): int +{ + $cmd = $argv[1] ?? 'help'; + if ($cmd === 'selftest') { + Clock::setTimezone((string) SW::$cfg['timezone']); + date_default_timezone_set('UTC'); + return cli_selftest(); + } + sw_setup(); + if ($cmd === 'cron') { + maintenance_tick(); + echo "ok\n"; + return 0; + } + if ($cmd === 'seed') { + $n = isset($argv[2]) && preg_match('/^\d{1,5}$/', $argv[2]) === 1 ? (int) $argv[2] : 400; + cli_seed($n); + return 0; + } + if ($cmd === 'jurysim') { + cli_jurysim(); + return 0; + } + if ($cmd === 'issue-card') { + $n = isset($argv[2]) && preg_match('/^\d{1,4}$/', $argv[2]) === 1 ? (int) $argv[2] : 1; + cli_issue_card($n); + return 0; + } + if ($cmd === 'sync-keys') { + return cli_sync_keys($argv[2] ?? ''); + } + if ($cmd === 'setup-token') { + if (!test_mode()) { + echo "Testbetrieb ist bereits beendet; ein Einrichtungsschluessel wird nicht mehr gebraucht.\n"; + return 0; + } + echo setup_token() . "\n"; + return 0; + } + if ($cmd === 'config') { + $stored = setup_load(); + echo $stored === [] ? "Keine Einstellungen in data/config.yaml.\n" : ''; + foreach (SW_SETUP_KEYS as $key) { + printf("%-20s %s\n", $key, (string) ($stored[$key] ?? '-')); + } + return 0; + } + echo "Aufrufe: php index.php selftest | cron | seed [n] | jurysim | issue-card [n] | sync-keys [url] | setup-token | config\n"; + return $cmd === 'help' ? 0 : 1; +} + +function cli_issue_card(int $count): void +{ + if (!card_supports_sodium()) { + fwrite(STDERR, "Abbruch: PHP-sodium erforderlich.\n"); + return; + } + $dir = SW::$dataDir . '/issued'; + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + fwrite(STDERR, "Abbruch: issued/ nicht anlegbar.\n"); + return; + } + for ($i = 0; $i < $count; $i++) { + $pair = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($pair); + $pkHex = bin2hex(sodium_crypto_sign_publickey($pair)); + $handle = bin2hex(random_bytes(16)); + authorized_add([$pkHex], 'issue-card'); + $file = $dir . '/' . $handle . '.key'; + file_put_contents($file, base64_encode($secret), LOCK_EX); + @chmod($file, 0600); + echo "Autorisierter Ausweis ausgegeben.\n"; + echo " Schluessel: " . substr($pkHex, 0, 16) . "…\n"; + echo " Ausgabe-Link (im Browser oeffnen): /claim/" . $handle . "\n"; + } + echo "Hinweis: Nur diese Schluessel koennen sich anmelden. Link der URL der Seite voranstellen.\n"; +} + +function cli_sync_keys(string $urlArg): int +{ + $url = $urlArg !== '' ? $urlArg : (string) SW::$cfg['authorized_keys_url']; + if ($url === '') { + echo "Keine Quelle konfiguriert (authorized_keys_url leer).\n"; + echo "In Deutschland gibt es keine staatliche Liste aller Ausweis-Schluessel;\n"; + echo "echte Pruefung laeuft ueber die BSI-Zertifikatskette im eID-Server.\n"; + echo "Diese Funktion ist der Anschlusspunkt fuer eine eigene Trust-Liste.\n"; + return 0; + } + if (preg_match('#^https://#', $url) !== 1) { + fwrite(STDERR, "Abbruch: nur https-Quellen erlaubt.\n"); + return 1; + } + $ctx = stream_context_create(['http' => ['timeout' => 15], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + fwrite(STDERR, "Abbruch: Quelle nicht erreichbar.\n"); + return 1; + } + preg_match_all('/[0-9a-fA-F]{64,128}/', $body, $mm); + $added = authorized_add($mm[0], $url); + printf("Allowlist aktualisiert: %d neue Schluessel aus %s\n", $added, $url); + return 0; +} + +if (PHP_SAPI === 'cli') { + exit(cli_main($argv)); +} +web_main(); diff --git a/v2minimalsplit.zip b/v2minimalsplit.zip new file mode 100644 index 0000000000000000000000000000000000000000..5ef9845244b1c42ad301f478415d11e64fbddb2f GIT binary patch literal 84174 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 literal 0 HcmV?d00001