From a0c908bf2f09ff6ac63b4e3b1cc693db12cc584b Mon Sep 17 00:00:00 2001 From: florianthepro Date: Tue, 4 Aug 2026 12:58:51 +0200 Subject: [PATCH] Add files via upload --- v5index.php | 5823 ++++++++++++++++++++++++++++++++++++++++++++ v5indexsplit.zip | Bin 0 -> 87207 bytes v5minimal.php | 5540 +++++++++++++++++++++++++++++++++++++++++ v5minimalsplit.zip | Bin 0 -> 84914 bytes 4 files changed, 11363 insertions(+) create mode 100644 v5index.php create mode 100644 v5indexsplit.zip create mode 100644 v5minimal.php create mode 100644 v5minimalsplit.zip diff --git a/v5index.php b/v5index.php new file mode 100644 index 0000000..8c2afa4 --- /dev/null +++ b/v5index.php @@ -0,0 +1,5823 @@ +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; im Echtbetrieb hier die feste Domain eintragen + + 'show_official_banner' => true, // true/false: gelbes Band mit dem Hinweis, dass die Seite nicht von einer Behörde stammt + + 'testmode_end' => 'gui', // gui = Umschalten unter /setup, edit = Umschalten sobald die Werte hier stimmen (Fehler stehen im Protokoll) + + '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(); + + testmode_edit_tick(); +} + +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 testmode_config(): array +{ + return [ + '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'] ?? ''), + ]; +} + +function testmode_edit_tick(): void +{ + if ((string) SW::$cfg['testmode_end'] !== 'edit' || !test_mode()) { + return; + } + $last = (int) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'edit_check'") ?? 0); + $now = time(); + if ($now - $last < 300) { + return; + } + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('edit_check', ?) ON CONFLICT(k) DO UPDATE SET v = ?", + [(string) $now, (string) $now] + ); + + $values = testmode_config(); + [$errors] = setup_validate($values); + if ($errors === [] && $values['eid_mode'] === 'eid' && !setup_probe($values)) { + $errors[] = 'flash.setup_failed'; + } + if ($errors === [] && $values['eid_mode'] === 'demo') { + $count = setup_probe_list($values['authorized_keys_url']); + if ($count === null || $count === 0) { + $errors[] = 'setup.err_list_empty'; + } + } + if ($errors !== []) { + log_line('SETUP', 'testmode_edit_invalid', ['errors' => $errors]); + return; + } + test_mode_end(); + log_line('SETUP', 'testmode_ended_by_edit', []); +} + +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; + if ($full === '') { + $full = '/'; + } + $carry = lang_stored() === '' ? lang_wanted() : ''; + if ($carry !== '' && strpos($path, 'lang=') === false + && preg_match('#^/(a/|favicon|apple-touch-icon|icon-192|server\.pub|robots\.txt)#', $path) !== 1) { + $full .= (strpos($full, '?') === false ? '?' : '&') . 'lang=' . rawurlencode($carry); + } + return $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}|auth|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 lang_valid(string $code): bool +{ + return in_array($code, (array) SW::$cfg['langs'], true); +} + +function lang_stored(): string +{ + $value = (string) ($_COOKIE['sw_lang'] ?? ''); + return lang_valid($value) ? $value : ''; +} + +function lang_wanted(): string +{ + $value = query_str('lang', 5); + return lang_valid($value) ? $value : ''; +} + +function lang_store(string $code): void +{ + if (!lang_valid($code)) { + return; + } + setcookie('sw_lang', $code, [ + 'expires' => time() + 31536000, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + +function lang_active(): string +{ + $lang = lang_stored(); + if ($lang === '') { + $lang = lang_wanted(); + } + return $lang === '' ? lang_from_browser() : $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.official' => '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.missing' => 'Fehlt noch', + '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' => 'Noch keine freigegebenen Ausweis-Schlüssel.', + 'setup.path' => 'Zugang', + 'setup.path_eid' => 'Eigener eID-Server', + 'setup.path_list' => 'Eigene Trust-Liste', + 'setup.f_server' => 'eID-Server', + 'setup.f_cert' => 'Client-Zertifikat', + 'setup.f_key' => 'Privater Schlüssel', + 'setup.f_client' => 'Ausweis-App', + 'setup.f_sync' => 'Adresse der Trust-Liste', + 'setup.f_nect' => 'Nect Wallet', + '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.home' => 'Startseite', + 'footer.imprint' => 'Impressum', + 'footer.privacy' => 'Datenschutz', + + 'imprint.h' => 'Impressum', + 'imprint.p1' => "Florian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + 'imprint.p2' => 'E-Mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Verantwortlich für den Inhalt:\nFlorian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + + '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 POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven\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.title' => 'Cookies', + 'consent.accept' => 'Akzeptieren', + 'error.consent' => 'Dafür wird das Sitzungs-Cookie benötigt. Bitte auf der Anmeldeseite zustimmen.', +]; + +const SW_EN = [ + 'app.tagline' => 'Digital citizen participation', + 'banner.official' => '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.missing' => 'Still missing', + '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' => 'No authorised ID keys yet.', + 'setup.path' => 'Access', + 'setup.path_eid' => 'Own eID server', + 'setup.path_list' => 'Own trust list', + 'setup.f_server' => 'eID server', + 'setup.f_cert' => 'Client certificate', + 'setup.f_key' => 'Private key', + 'setup.f_client' => 'ID app', + 'setup.f_sync' => 'Trust list address', + 'setup.f_nect' => 'Nect Wallet', + '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.home' => 'Home', + 'footer.imprint' => 'Legal notice', + 'footer.privacy' => 'Privacy', + + 'imprint.h' => 'Legal notice', + 'imprint.p1' => "Florian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + 'imprint.p2' => 'E-mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Responsible for the content:\nFlorian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + + '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 POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven\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.title' => 'Cookies', + 'consent.accept' => 'Accept', + 'error.consent' => 'This needs the session cookie. Please accept on the sign-in page.', +]; + +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-actions { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center; } + +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']); + $b = base_path(); + $a = SW::$base; + + $html = '' + . '' + . '' + . '' . e($title) . ' · ' . e((string) $cfg['app_name']) . '' + . '' + . icon_links() + . '' + . ''; + if (!empty($cfg['show_official_banner'])) { + $html .= '
' . e(t('banner.official')) . '
'; + } + $html .= '' + . '' + ; + + $flashes = take_flashes(); + if ($flashes !== []) { + $html .= '
'; + foreach ($flashes as $f) { + $html .= '
' + . e(t((string) $f['key'], (array) $f['repl'])) . '
'; + } + $html .= '
'; + } + $html .= '
' . $content . '
' + . ''; + 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 = '
' + . '' + . '' + . '' + . '' + . '
' + . (lang_stored() === '' && lang_wanted() !== '' ? '' : '') + . '
'; + $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_cookie(): 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 = '

' . 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')) . '

' + ; + $missing = array_values(array_filter(setup_ready(), static function (array $row): bool { + return !$row[1]; + })); + if ($missing !== []) { + $html .= '

' . e(t('setup.missing')) . '

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

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

' + . '' + . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '' + . '
' + . '
' + . ($stable > 0 ? '' : '

' . e(t('setup.check_keys')) . '

') + . '' + . '
' + . '
' + . '
'; + 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)); + + if (!consent_given()) { + render(t('consent.title'), '
' . icon_cookie() + . '

' . e(t('consent.title')) . '

' + . '

' . e(t('consent.text')) . '

' + . '
'); + } + + $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 = trim((string) SW::$cfg['domain']); + if ($host === '') { + $host = (string) ($_SERVER['HTTP_HOST'] ?? ''); + } + if (preg_match('/^[A-Za-z0-9]([A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?$/', $host) !== 1) { + $host = 'localhost'; + } + 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); + $to = consent_return(); + $banner = empty(SW::$cfg['show_official_banner']) + ? '' + : '
' . e(SW_DE['banner.official']) . ' / ' . e(SW_EN['banner.official']) . '
'; + echo '' + . '' + . '' + . '' . e((string) SW::$cfg['app_name']) . '' + . '' + . icon_links() + . '' . $banner + . '
' + . '

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

' + . '
'; + 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_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 = lang_active(); + if (!lang_valid($lang)) { + $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(); + lang_store(lang_active()); + redirect(consent_return()); + } + if ($path === '/lang' && $reading) { + $wish = query_str('set', 5); + if (consent_given()) { + lang_store($wish); + redirect(consent_return()); + } + redirect(consent_return() . (lang_valid($wish) ? '?lang=' . rawurlencode($wish) : '')); + } + + $public = $path === '/' || preg_match('#^/topic/\d{1,10}$#', $path) === 1 + || in_array($path, ['/start', '/auth', '/imprint', '/privacy', '/api/topics'], true); + if (!consent_given() && !($reading && $public)) { + v_error(403, 'error.consent'); + } + + $langChosen = lang_stored() !== '' || lang_wanted() !== ''; + if (!$langChosen && $reading + && !in_array($path, ['/start', '/imprint', '/privacy'], true) + && strpos($path, '/eid/') !== 0 + && strpos($path, '/api/') !== 0) { + redirect('/start?to=' . rawurlencode($path)); + } + 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 === '/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/v5indexsplit.zip b/v5indexsplit.zip new file mode 100644 index 0000000000000000000000000000000000000000..958253e7b6c81795fff4a4dc24dd30be246a322a GIT binary patch literal 87207 zcmb4~Q;aCgwyoQ?ZQHhO+r8ShZQHhOyL+{5+jjT;&(2LwPIC9zxs_C<9x5aCQge>_ zGN*zxFbEXDe+>pT5Z!-m{_lbWfCFIUV(Msa>S$=>)0uc`tG0Ngd?VEJFu z#T^;|5ahqk|7%hBw_0fbPc0`$`u~^%nC3jWg!%ptRvEj9HDGOy<&B5FC_4(MO z>_9Q&1@!wdN_Hq-B>(gEX9+#b4^n;`TlH*<_wx6{4iNg@+d9;UMDF@t(U0ImO>44y z0A7@hS-n`fQM!EzpQR~X66zE^r1i+AZpB6v3zPT_+`)ZREGe0Z)KLjT%2ILrA{|5_ zeO5C1ys~q`te9V8WI_9~jGvgc7>NY6H#>oa#Xg=?gh|mPgau9BU(1C#C$TZ($aGkm zHX@HsI`mPDd8>#pl(C;DOCcd8B$7#{e80)$^)7`eWBg(?ULdn2TsmfJTqTJhi^`l- zFCjO@(sIAAfoYaD!WeyyfaWgLL@aO&zlG?DnT?L(p1mSf0DKqi#zJOv?-1Wqh%!5M zBD(k(!~07;1aC1BfsiOHM}J8kJKC}BWxFKJlupmVGP4+f9fqq^GFvBR>Ij|Lz@1_VP-x5zg`j|kis5RPZI{I7A~yU>Nk(cK}bk^FwV;JTPH95&uz z1_$8&T3!Ro)mU?aD$matqA<#rm6HG9WE3@pe z(qH0fHc_jHX-oS?z%BohA$EatrlAOq&{8nwv}`QnBPg^v;EX7GRj`*@!vjeauXBgB zmAT%PH*N%QxI+1FA$Qbu(%c%wn@tG!@eRzqI7!n3P&|X^`%NVRMd?nkm<_||hzB0+ z6e0JdE}TggyXhdwJ{3wR&8x=FILk7=bz|bRk5XSB$!%kPgo2AVWJZ4kGuUj71+ebP zLGL^6n%bUpj=UUk>OA24H@Bdcuo{+|F#QOAy)=&*3r+9`&g8Ip*Mqnm(rUgvB5_%* z(nL1^Ir(qbOSEvQLqFT<^7%i_HgQk&Tgr;d{8`sv4Il|mO3+?rm}#Eo1tQmmWYLBr z%YR^y$p0!RCGRb{+Q!2l#igBai3XgKAq;Gp&Pd6%Tgkrf4!#2!Hk0+~F9uz` z1)6NHmva%&S;Z*XE?UcXvziC(=G1Kf=9>(9+bIxq2XEqyf%qJQI4T6ymZwJ9GHxd> zjA<)f_BT zAvFy?AL$YJ$Rl@-us>rNOBFfxCfF!&$Mnpw%K{r5_ZEn;jPH1^dIq-W zAWsL-Opzr&yRM$j&Pz$!iVDyzJ|wWJW3^obW}Jh4mr49x{so7eiOk=``H}GT?G4Gx z+g%jukh8lCC~p9EjP{Ev&~(X)7g$A>_TncjXnx~9!BYpWV?NK^4x4M%yYrJ#&-2+W z^1BW^en-j_@^tA=n8i=J`Q;C+b$RK_Dge*3c-YksZXQ(hD84a#8638> z%Q>#XYHsP~-;arZuRM7Qd{0|hw1>)iOIiE6Gxzphu~mcGLpvXl#1Z0+fqf(7eHysR zoIQ0qo^BQ2ryf3$58RYZZ0Da=+NrpcFSi@!Mslz;fsyC#kDQXKJMDlmw>8UP08cQG z`d#DI#)d8H163Y#G5MoTDP0=3i_ISYd7xRx zPP-g@t0{s;O9v}DXW=#hV|o46{;DFPC4TG4-$BUDA1Ry=M}NPArrAKy13l%99_lrl z9g-cX+seg*^8J(hW+BQT+qxu`t?%CT>f4K=c$ebh9vMVJsL`OkYbGvsHJ)zK0sOl0 zcwIct-P@bJeIHIc>!Yg|UT}8!tMC@qi^lK0L07{~+)hI$Wbfkd+v8#As;&ovL32+Y z%p1cL|8<%tY8(}>y&F<92HaoP|= z_?fBG;FgiMGy#r1Jl``I9g#YyFy;y6z^7OqUjSqePom0+wz&G;>EVVX3CNjwlCGLC zkaTPNylSeO46|j4vTP9p?w%{NNryxvy%sZ`9H!~ve;O1uMha$h>PVlbfWw9tcYMT+ zNwXy=_XkAwEAh4*K*xG>7w!OMJA`KEzzB7kBs}KZ>I{7F;)uM1v(dgWmWCF8IhhQXBwg|XDRMv8e`+!VU{1g`dp5g3$7_y(OSVS?MNJN8CLHj`T z#Aw7Q3n6d{1EQ@k6x?=K`~0&l*|grwhE1&gH@tHsrdSY48uED|9jPaHL*sw~AH&Qr z$s75>Ks33KqeK+&CXh@2ZOf*%R8FgUV#>NU3W4j+Tz57{iHoiYc6y zv9i#aXgC?BS{QCy9Ioo2sTKhRc(NGYR&AF%CR{`dnZePt$Nb7#HOp=6ckEd{Tn>u@ z91jSXxB15du<3cY!JU7%b08ZBNZY`$pbV>?wC;ZNFI8OCG3ihs*#M2}DAa8j_dmHJ34Mc9i*22;Ya=h@Y_!hofHThUMtO>uv^s8`*UH`gb|GAUK* z2%#MT5v)>BWTa94Szx;)&Cc?l-rtWF;EZuN8%h#e(Rh$WCczB@A0>C}C%Cw0;Lji= zE3x)lHyrOUY0Tc%64YxyHn9-=VJV}AR)HT5l`9f3)#(62X{MAk;Zcf^EdIyeX2=8% zJ~bsu74v-4+)~MEw>w=>c(5dpiW`ozn%$PLYEPPiPe%==IyLg9Xp(5o5^2@qu`%J= zkk+tZklhbwFyQRE!G<^n-}dt!iZg%w8K!$R_0cs%!>CE}hN=XYfmPBLM1#T{r}}bK zSRSEM(KAI@bri=MX;*?YsrE9QRLt8VT|92rydlC$UbrLyd@SFw92j7nvFCved|qo} z-ayHWMN=tE_cq{>0v#7PyP8eERqlmvq;h+~ojBzyUoUKu-$PKSY3v{NYV=e4hDmq| zdz=K9(|v(OMPUCPK*$Q-N}W~426~BCzh)H9VKCjf?b!qCc}m3BzNlh8M#MbM>n6ST90-tmU9tQG#Mx83l+={cKf1I7Y@> zUw5^G&LvuG5AJ_Ixzj5MEn=Y}pY5utq$|-d=IV)4qr=hdlU(B+{LS(G&S-l`( z?;#Rdse*m3B1vtKx{76=?O4=IMDOSkv#TQ9-i`Mn?H<$7Vv`E#cB-y_4Tzx&eWyRQ zg#__!b@p7xuc!MGI=U4c#pm7A!GXWQP(LL?i25`St(5l>wh3~OW@0EZQKxvqeq^en zMWfJx8u~tUinJ-J#QVGpnJ5JlP8G1Zu7IXSlx{MLR*@WPm2`Bg{U!1v$Y5RI$L2<| zxKt(6W=epLkqL1WB0$4=5EOc^WoC%-y?cK9to&W{pu4(d~SX4**-cv*Cb-vf4>e}C!aF3vc(b-)pYl23~GJ%f96B- z(5C)&AOL`KPyhhZf13|w4J~Y)O>GTrjZObkP7G1mwA~Ou@Of2_MnJlZ3@-H{Mi1^+ ztU)RdT;vf+Xj#|2Lpy9)u_9L--hIOU0DfC*zl>Jo5{#vHlJ~13lCar^yD>& zjN$_s9hU4{)s{joh98ru@rqKgkR(P9*QnBaAqD5=LeZgv<&hc)(Tfm>3Wvrhh5r8F z)>Zx{C`LpqMhv@qmLCKH-c=1&A4?qpg|0v&Vbo$e%sSN|IVFFoFr{4x$E+aIP|gUl z7!I~0(MIfo&q&my z6uFZF4c)YxX;B7NxXO-zgDEraH}sZ^0dRv`%m81^nkdHP2okK)M(x55iLCkX`C71# zb}-sS7sR>v^==7IVfzebHZ^I!!nuo15Jnt-w-)3RYw(wc&CcnQrcsJ>DtgtRO-4%w z6`=BDfswSjk;zT~&)a+ObX85*EK^*tS{Fe6W2ea0t&&rfc@m*wMaotj0q_vIOV^T` zlBZwc`!Y|Q2(3@&of`?|rv=DTPs}|BI%-%_X(`uvDo2s9l-r;28(5~9P&3bZ)hTh8C>abAtazj zd(axHX#L6YAeN`G(U2!-p`t^WIa`RQf#En1-`u4R6`o6LBP1KP443cpq>#dxDh9@u z7v;?z0mgumvmqcUIs01apW0OEi9EW`|5?HnzfP5&c7YEiR_D`G?Wo~di67KN5bwmu*O;#@P1z{0X>8fM>uJG zB}gSQ&~xuDEuv++MX?)`L}L!3m~aqyd>Dp|ii46aLTLt6%Q)tD2??q~RxfoArwr~Z z3S@UI)}gb2QF|9^5TH90?vP9vN=t_kAwrQ0{kIJHbBQ4_Q7rHX1?dSPl4t9Jmn+`Z zaM<3+%U(@3rVeae5UG9%I9;yHz?LM@Likw%HdJ(iBAt;<{`o-9FAi!LB#d&ej3~)e zyVRTut3Lqwvw0flm=skF)n*7B(t#;HCslK0x|Z{tw=Hi4+$h~#Vj(CFKfs>u63TY4 z#E(_`Z_p-}ccb2>@DqE6c_6gS680ac(_F(rFY|;Wm?DZ0I64A2VpQ10S%w8|dpGH% zyg3BM(A=8TfW=^L0*#34LtWjM*J`7>03C)fON-H+v){^IFYq3Iq)FgOiLJ<^+_|-Y zUK2KtT$krUqsk;C55Yph5fm7^AzUOy%Z$Umn}a%J^9KultW~Cl=NHMi&Z{b-a#N%& z_Z^CzuRG2%C7l4jYyD~iApO*+T>)dq_V;!D4*?QHjwDUHV?t_@Q-3eIn$2f&)B(3DZs|oMmJcv~y?`iJD zAm`XWHANQ?)X+okUr1DWOUTy|8)~uxxEGf*yo>qNywu2URhIAdN@$Lh@6}(wann}c z*NhfL)9XBUY1HY{>QQi{Rc40*poQBwk~z>gVG_OPKOB8O+wlEdPWeA;rHf{`B;*Vnj3{!fSZ<(I*%VQVBUtP(sc6!ynF5I!wKL^PD7M{#x3+H!*$y{~kzV|JxWK$;e^*|3Rf8->NSqkO2Vl z*#Ecx=f6Zv{$s9f(cW~#VNd)AOcRg|8d3&YCp?()EP0wsCMB|VYH%mh)~#UUmeZ-A zmsTm7qC~Q27leDH^RL0*MH%r6eioKp^JmbnCqd@h>e6mvH+dqi=ZphAI6jyZzRCMl zKU}QTbh(Wb$}C5W=Tu!Jb-AT&9Lq9kCtr4HDr(WjOsSeqb*aBfyaDyu?xM*RYelHA z*6JNihNEAQmzlNMmRKrERN1c1T!dtGWl1gT>O^pPr&0NZ;SKf&BQ; zR9T)JHO`6o(DZ(p-`{tPKAIK`ufO!@Qe{z;ITxhGX!?5Zt&%KYn3|iWo`w47GWwyAR zI7S?_lQi5Va(_CVbY> zV-K&seJ*r%DDLCHU%za>m~(upDj&vQ>Xkno1+je{4H;-1x1f8KVySjbj2)QMKceUj z3Dch^r{b;s%8^BGkzMXE7lN2sgmw(P9+>Q|3m#QXkyrX7)R z>(QlI>X{9IWn&CQn@#KE%RB0_=nRgVG zbSs^2S*Vu}_@+2u3XNSS*!j$KLQXd~Ia1bQ3uH1lCPbY&{8|J>8!Z7ZEJ>>-qjUqB zz(eM5l*Pb#D6_fxs&=-wY>npgBi5mNRG?Qz5hP{-nhf@ugo2B>eC6m!VX)s|8?FcO zptXy=OV+?doZ*wO@tpx)ze{8%IOkd&at-szTIMAi;kQ35hyaBs>=Jd0-T70dQ898W zLOGv(hVx3I$%cjuK&zYRzm?0F_6O7@$Vd@r?UiJ%M5qDdneWrGBcC>+nSHt@B)R$1 z3y~e1ogJM?cxP!h?DPs(=o}HlD?ZE`^Ln7Eg()C=>M)?$`)~}BI%-#4&vM_jQ26)z zyhY-br))Ye@J-Y`GHsYtSa#rrSh1-ilF|7i=_GO+!zWqRTqr(*0p=TjgHw<;U;7u^(D`0jHe zb1p({l1>HX=TBUHOV0XCvoMRoFiqPnbeW;)Kxr}xC&c5XHEw%m%x9%0E16U7DMIqX z#LZt5=M_h1EJ%8=M6U-KN9HMu=kh{*JxaP`W5oCZEqQHhKWXW(NgSt+$pR2U?pd z{ogcfS>9ZULZwsn`-|3IDcI7axUinBjv63fih4bI1XGWZ))y#f6RbILaU06>SCVbO5&ee`#JgMB@7g_hdP%SEhA&a)s#d4b)k zVKFlK>I>HO5j7%?XSp7*YJ6h*Uxbq#Kv)5w8nLv7(0_@`;+i%Jt-RAn&3Fm81n`K$ zM=_K1E@;=v-U!4aMgAfBChRfydV;+h&SL_t%w~kZ+S9piwX2|W1`{f|XvK*$b%LEuh9+|37{YbE3kVx4zIBZ%M34Qm3n_b{rnE`CY~xRI&eiydyp_BDCys6e?ZSR6Y8F)rQz+ zes5JyNsB;%i#=?mS&@~$k14noY0NwX8PQYt<0|qPkFos1yVr=}QJBcMP!GL6ApLr+ zbB@7$B$=tm!^PjN@*=ekc`meCOHZe#(cBlNBNuuAKw1}E@=6IxP_ahp2degGYoFRX zpSdT|mIf-yed1J2U$Ru|dcbX|v2mdO@UFSi^~K#7O^~AHE`mG~pCefj8olc|1exAc zv3y=kwa3wgSqyxjsP${edB!R=wUwqk_F z)zLow5?9PU7y}x)jAn9#s!+B}C4Drs^tQT0&{}Cyoi|cWY`-_plkV(28WT$HO!F)_ zR?iX~6-;)@>z5yaZ3ZI%@z3U8IK6~e8S(LpgIVMex=~M+3I$e&VqIRovr}OiU`E50 zWz&E%m5SZVWA?IH_{05#^-VX9vMdF;b$0d3$?fJNNge``{PW=0Y3+h!Ucsg&8VGFd zk%ZRA0ybEUf+#h(Z4V}k41E}L#PXGR+!vj{gi&{qbNg13Ye2BhZbj~sjkJEf`FeV& zA?9;CgbCuvHlLsRzsg^liY0{5QFhi)I>5Ij!y6v{^qybo9vxUksMRO6(oqYX9Rx`b zS(Mdf9I59brR zX%-U9hD1&X-LJLtgT!n5exLiaQbS(?N6#KhwURH{V&o4&E`9A11&WY%iEx~WlVWcZ zGJ7#Uit5^>Qvts=nN-+8rji<8I458hrh{iOEyC#;1o&E69F}ERTHq@hj-|g<}WG%8- z>kq_#_NN8(D@``Gi@r5!DQ~PXP;)@kcfxqx8UCu;G0)u)1A+9fUnUT#9LZt;w+3eMyE=Zkx`3{YdkIiQ^ z%@b2=yUP3)!j%D(fxY(2zAD%wnmVfPtmUiHF z$uiKTsknLfSin1&cboTUv>;x|>wpV~Zyo~2p({7eHZ}HCd;BFftzr`ShkGm>fo#0o zh+-}h46d!YFrWQBg!D=ZD+63H6)I@ZxOVp@oF>|t$|j3Y4XB2z!Z4_3ae7uRu>lEH z@D)uz6ZMWk4Q#A;uz|!{7Nw#VcF_eb{7YFc7sB%mYx!LgsLIclb3mw?*{+Rot|5^X zt2)e=)0$S$qes=NTQt&lzLt994GnuMvRsGdP@)P5O4j5PQZ;Hj%({AT4`!7mnstxW zKow(=FaArIlS}Gh^V6N%_G$=_KiJvyh_!SARM>?UU!6 zWjOj{&oMKGX*+h}pl+!BgLwkaE)9K{nuXaVtW_n;k-d<~Llr3|W2zZfm-j9Ya>)}4 z467*`f0Yi}EpaG>8$FWH^E;-BW-scg)a~)2tLX=$6M`K~7oz+{%1O&=L#4jxYfoV_ zT)e}u^JJM0_GJaq3Qh}F3zgG z03tnQo8J^{-0;X9Yj(idUh3MdAFaC-UFws)Y8xWp5X{pwMEf8w{iEI-cAI3wPmss1 z$~lJ3(IjjCnCGCMuWWZlMz?*Y%xiA|=MJ#P6G+ZD-r3)h!&j+~SLy5PNoO3`{KlH! zKV-?2tJcU^p7vW0qR+i}M)(9y`r-pCfd0Eab{^~}9kSZJ!u0OfAl6mozf7Ta{xc|u z2^tRC?)$p(77^(puCm0_&sNs2bRlC27}UB9@)lPX2e#FscQJP4);Rm6uNyj0CPGOO7r4jyRFY0kAYBXV4X-qSZhCJh`bRc6fT7>6fA4xKu27+&=gFVm>C8i&|e0OYaVybl~c!CMV|YBEK}=IgNSC4{`EHP`UV3@H-o%Yq~hi_ zBW+xp=~O2IuM>U|eah5BhNbfe)}MQDzO4Z%h@_K6b8wV7^zkEJ=A-|UaqRUpd|PFV z_O=cDn_KG+Lpu3mfaWHB|CKd`4xg*zO`psE_H*}|hl7jf^Ro_rVx&tfgriTd%k@vx zrFQUs3VMHv^2+lg#M5#q`FlGm-+`WO|AsQ|Oys;Z(?u;xX%_a>luk!W$3@ZrW+*XE&XNIg7l=#glUjFxeHav-t@@Y#toY zFXvf;j;Hc55uEy$c(34tCp#dGT2R-P*~* z{I;KdrA~ST$qqRB8-|n#63VAg(!s;47uqJKhEy?i*?~oV8WH!vXMZJjLszc}?Sj|N zFJ1Mgr&?jYbDW1nGvn0V7ZyUQS`YVulk38gO4X#)6R_uAr)2jl^#n!s@K9y~-;fnu zZ&(%nC-b+D^K$>UUh zkIG9dSeCtp)CtxEL8kHk+9k4_G}V&GJEr4WNxo~UptzD5grGeTX!|G5zIHTrr#Ds2 z+WJI?d}He7xEV_3eSLGaLFrWpiKlOB*)acr#P;By4}g05nRz#^>F`1$jh8cU7e3op zf;^YroYrW1u7&Xfuopd>-T7_P-3kdN^;(4LpVrnmWCza!AJo@i@%srcKnZv*XuChn zsaxAurw;wnNp87iNW{e-?gEl>nR68mDWxa?aEeI2a?PG(v7&-%)5Gt_L$|&6w^?Kf zBts@~qj`dkRMeIDlCJIf?=lb0M70B{FMoiLMyoa~M zszQ09(fD9uKzGyPasbDhnPmXJy;S>FHe3qb)+FJ;SqHBK@a52R4bU72@^550e%*Vo z-GFFjsDLkBXPjv6lEZDaG=qfgVS0PYxj&b8;v_whfu}3p#EH?r^~Jm!=s$n1>3DK| z-HC06?`4#a$?j+(iSSnq83X^|7V?N_VIzact`cT^AjquB9X*#EL%A}*c5z!c+(G}@ zXXl8=Bxu44?I$pyOm%~O4daN-AG{VPZ74b%v&ubpcd`ey?f@+>phAetPQ?YicY{SS zmQto~PWASY#lF)u(!m8U6uPHY;~T%Q%2{a9p+@x?E|84B@`n{W*}A^hWxwKhNc~kV z?oT3Cx=Ktaq6B+}H8 z?_d^}B=d6M5^D_aI@%EQCSV|aW$U?AEF-zNsYw&>#sMGC4}OaJ!0(a7Gx1*Oj##2r zcL+R7&#Fp;rm3p0TmBxm%d&a0(-Q_SYC-Kn*s8nQj1G@)>)dv&KDBUzS8H|I%(AV< zI>XoPB{MvG|Csly>{hrSMbT8@qHUXk$bR8cmil_5T3KD9{JJ@F-bmdkT(luSJDa=J zrLMK;dhn5T@Sb*{e1GZ%-sf*etXn3%JuavH z_%k@GC25?jz444yXL4=sYr#zz6CPw#r&e==0tVtrz^7~*wrZtH8Pd8NVKj2t%Dc4ar zOoffz_RaG1@Ynpz6gSx;B5WkwxT=`C&G7KYL&!i^9Rs!XLCLNa>IDqg=|v9D0oY+r zRK6+uWuSHmFZx8{?n?5pa7mW| zp2uQS9-Ya)kK*RCqP{qPuof8j3&Ae4?FilO?`Cm(YjccNZGC&$<4+<9o?caJYQmZ5 z9T|pD*=Vtd^JTJak-nD*2KJ<*v8nEoV@co+e-pAQZBAkh>xQ*sqq>8=)MmV%KUrHs zELdqR7g1>Sz+>*xOfgK;qunpA9J>C4|ARV*U*{v>;}SPfyFRyB8hQx?)?@8SCP9{}5A~ z&2mOx9Byq*;OxTZmP%y2jg1Dkt2VaUV|;Na4Rk8cH7DhSvYm!2W2!lnDq%s18w*1q zC6x^F*fam-tngT*ehL|RGMsk#{3vojHZN5cKxcY4fzq4wNS#q-Z&~@K+o;4-rscM> zRO3Oh4kgirS2M{R?ATMNGFaJpPH8|7myqIOq}NrSRql~6!Tar7-Fg)BsiKn2K`11sSwnbKo<_ihebHBg$P|h51O)IwOf|PSa{MLxyd(aRTGdCRI!i{S!60KD2 z)rg~!8%93lYLV>F((Kar9^?SlVCy<1mLc=USDp11nl__GYUC8MgN=Z(n6$*8gvDg@ z9PiT5N~6F>q(L;@Y=QcV5b;aUF*@q2E*vr)mCNf8!J+qoDK{Q4MYjxXU>=}`y){RK zh7IwoMn)kNq6Dg<$w)FSD8+!2VD_nza7a8_vQzbFuANNd(VY_+3&BKY`N@JHxf?P8 zl)w|%zS-Sn{=QVnbx|mPcQHp-Z)dQPQH3og%U!ey`^GDShpnxUjljDhgpGI4)Vt33 z)m5OIH;wl)Y=4CSq$148`3Rxy8%gT(gka2K;mNYxe$KX$<#X2M4H)@m<}v%d4>mI* z)*`obg@1mvA%TAZx8#!TT}#IFTRZE58fg*`z_*UgClk3xQVDM8iAOL)698Z- zg=bFGa5q%mgA=p*Vb*bakieH^XSx&S%fAZ8?4V16JiJ4`)B;qia4NqrrJzKBElxep zGRDM;1u$m&!MBiM02!#zlH{#qF?nFmTIe1NDdvpa8sZn-l5^(zlkuJIO*Bw!ok&&( zk_RjjuY}Z6xVh3N^+ux9STc}GCKF<0 zg>uRP8^{ivF%YOli7Gtmm!VBI#{14eH2YULasyrmls zMNoih{Oc@+fI;*`g=QfE+G16fJMG{y3M6WpvU?#X^St_t){WH!I`g=$Mei2tM)TkkL* z+|KA0Pj;~>jFtg5ab?pE6sT?8Rsu+7qSBR{}Sf5I9+d}&@8d*=i&glW)b1g15CfNL_3^1_0>hzQf z@3Y`^5D~|h*@zL!!4C*H5O;Sz%lhDxuw1*eQ?(K^ujzstT{>2}ZVwUg*;+W-Q@Jqm zZaPfOkmU&v1UAa=E9c1N8}8>Ce<$`$C0uyudz!lC-0GYns24)Y^B^n13l1PTH7%1N zQpSdyS|gTnvB94^h8fL$>Ym!8T0c&70n1C83QYcOMn-|u#!9Zf%c3_tQ(h)2u!Nsl9_iSqaB z!J#dXe1B{O|2V6Y z@3H_n;^#jo!!ro~#M9t2;XHUYyHm|#Aii=2anuSe39+nUBDkfy=+aJ=fH-=dT|x0a+eh*3+JVBQdx-0`U+~nu=3q57W2xMRT3y8`##LxycnLvK z{`dQhkG;LhaMR{xmL(LV;h$8V9qQ+@s#?q4-_vSU5U+hGec z_!c|*F$xOyIXJ2aA&|0LC?i8uf&xSt(%N7Kf?(O$t|r(fI9h#=6YwhOANf3SVFrDP zeFK(0;s$C!DV6Mv!0_cMdUXMH6k~-~w1V zwr1HpetI)a|=e#3bz<(gP)L%e)4|VH091v z&r{PK33_;of_{?@-f6N#mQ$I>DD)p;@%ucVSM=jv=3`;%+TPAykNc5FyOHH6JN&PQ zAaQs+pRakhllR&hmDPE~NUNpQ->)pB)3{z{eBXz+L-}Fzesoh`M%`nFxsf1TfCHg9 z4x|j<*G<(rocx3h`pjP54Vs!w9LVl8kMOFupxwkxC*f2+gn4`F zp)mm36scP0ny#SRR@Um$wJwQs+cPlk%iCWB{eB(xp4s1pZxW5so&v2hns0>9wTz8e z`PZQMjYq1IZH#FonUnd10|f15^%We~Ry$@J_naa+XN17N8a=7pD$Bt()%$6F?cP*x zQ$=iG;=6^ub4qIg-D?Eo3<^4<)5;@1*F>&^1FH&NmO-?2AFj@h_b_s}WL2MUD10~w zS*lZ;C6i3oNT;?an4KWuVuDgub}eq(1SHT1r~R#P7l6x1VM|sd#+Q%jnI^v!JL9501cUFJ4>>#vk(fLzsRT~qC%~b@2VrV$ z_dz*)Gm>bc%|p1nrrcu>-aOf+(*iw-dmRW~*@5uknRd?&{Ut#g{Jnm@u6{mbd|s~4 zljaTSVANX)z5c1`BW_TY&mQs&cVET<#=@_3tQ( zRpXnb<(3XXZk(m_kc#cL(1SY&iw@)R-=sabHj8k5kdUF!BGb4j)mg~OR;GS$;1<$#Fam*@Baiv zzAH55!Aw4RL(KPQRkjIc=^ji>vgOFnCf$B$Yj*wf=V%d}=)JjoV#=XTVLU=t1f|je z`H1uDD5|Z1250V9Y8$;VcXj$XqiA|tXNFV=X$~{$u$ZD zY+028J)1?w%Jrue+yQ(|;&@NnfSFZ>`nV~H(C7X_ex5m+1ZVbiDojSJBpg_xGU0#2#t>)AehB3IIUxZ;Q4Bot#Xa|6|qW^gr!ASrSM; zwfc->m1ro?kaJO{QzV~>HJeqFWa^hHFBjHnmcd{FA^x}mV(ZU-eK(i_1O+Cuug4}6 zSZ1AhzB%W3+uad8?TeUj5y&-btoI8naEc5{*hsh@Uky|%yQxpclW)t~~nvCT6*%p$MKIFQ^pXsEn>sfAG zwrE@x_#we$kkaUq$qwQRAyy09Z~K*QvPZ_Jac}|ZtIvW24HG#RBoR_qt0E;1JpEyO ziuhK_aYmzVg$gtKf8wuJE{1dj-s$FlPKpd<3wIzR;Ct{A8t+ClGw+j$_GW1!-uK&panL`=wOk=cKih# zY0FM?PBV*;M%}Cl72>{|qrOznOK4-}#ZxdBG|xi0Sxr5-f=@XecqReIXOz_Q0-CTC z5omhCegJ`Uw8z|wxidL1p6Ud7@d#A~JCwg^b(;u-kBN4{$fE=6O>82t*VfTk{~myv zOX7q;Y8#kY85`@o;po%6E_#aaim69iUBofPeu$6HTM-qyA~={Q8&-{195Ui&xTEMK zacG0}E|P0!UK*tEW@3eU4nNTM&yT zW?(xI+!20^X7|6w$jS)A(9>PeRR&YM?(a#e&#B-qrhW--`5%8|ipz&U4}WFzRDpx@ zt!9(wXwE9J=U;+ye7nD-*XjZ3$5IEVz2pGe18e$i**FkH`}uW8*q7SPNagI|V&pBY5Lat` zfTp;Uqqv(w2~$F?C)!{zWbd7X>_KpOSK=@?!$RW@#m))C1{@~`U=HW}#H9U-G(+e> zH)yGH@u1sspP>cf-y%6Bj-9##K@_kIF~ALR`2GsEcR1OAy3r~%<(XR|#8nG{pOH}b z5=CYH9Ue)Xhn z0PSJnmytxD=C!ayy80vb;Cvm*fM#-pT8q*&#fs+q*eHKgMggv?{#QFmGB5delGO4b zk+HrMIT-457(+&elg=U)bQ(jcqc~$G8Vt6jSFVZX51lx;W|2eEXi-3vi-m!VnUNvg zl-er1Up|gnVsdG(Fx9NZoTt*AX7;Nj=az6(Kj(Ik*76@s+9HU%8>YxWt(wal$ER~7 zD3196#NN}EksckEDAA@~2oL|r@0o&ifMj)`OCg4Rcl}FB5mT`bY{Nr`2HHj$oX#;9 zmGH^nL;!viBP%HrAz=iM#s?DjgYJ=P@&S}v?dU>56r&A-Pin$9p}9kM{h}z$F}6fH zWZA@v^2q?UI!%4o#z1f$f}-#1wvy&mHhcVOLQJkJAtiKJ%?#9RzJR;N$5fx`-ZQJ0 zBe5Gq9CmE%0rvWWx`~2rJd|Vr55V|UfQQAI@+rF}8^KOKn6%@1L7Idlr8W_&wD5gO z=a?cDvI%$`+DV)P!or*>C%~RmgChgw+1a@$l(p5fSN-C+;dx@$A~Lp|HCy}Gr~CXf z^v$TRcXRB75D_&=XR$!w&jqx=o$w;)TaHWSW+D*uR)fJMIwHlHba+#NNzBWAl=BX! zpNcFoO4JX3H%s$tLEsF@*fCa~Zm(c*`>y3;v!OtJlyKxkm!u<+I1Cy z{ko0{gZ(^**z5CKl1SU%z0eL$lZ8vBzsA>jnRo1N@w3g-Npo|O>WlZca&^XvG=1#I zyau}dd2}I2&6l0J-o8*Hh3dE{Xp<5ic-eNpeH*Ag998>WEp3c}XuH8w`^idYBzQhe zztDYhi(;O259HZSu=Nhn&;&*qmvZy{-;H0iB~J71?6t6m*xiP%`sF&tHgXh8-6VHm z_E>@@e8GahjV?5Uq5XR6%%K{;y1Lavl*bXio06RnJdOy9hz`nne7S+`$h~8Hlr(G$ zk_EQ!J5ZSpj@Arlh6ixBQbf46h9H*3tBEgUp;RN8SPY_!^f91{FiG{K!MY_#1`Q7c zy>|l`teQ%EiXz>i{c~Z~CJ^oIw86F{p}`8oa(>dlG7%`ujxs1*Ys?dg?%G%xLGe?c z?v1|eLC8|yt@J48r)OnA8yukxh?)l}zW8fyNWSdkM?zGO$qV_fGs`8@1_zcg8|zOV z{&&d!ze7au|DPe-_l+n~FPZ zzuz$|_(QUGKFmV4{(y=N#!Z(X8bFkl4dIecKB=LTiY9$C#=x_iOVcZ+^4s>6js>E0 z6HS|Lr7Nmrhh2Tyt53kwKm}d>ux%nT_k4!-;jrRSWr6Q{KjU`w2&zcX4B2PJ8_SYW zUIK+<^k-iyb&(_#Idh&)_aW5l9gGzCGu&k+E$$#2etYgZNVt&QM^W^x`VA+Etoh2w zQFx^)hW=0#M5nI^%f1S(lyeO99nZnsxIIIB$_V4n&{?9WoU~UlynPcw5YtW-v9A@{ zXp<2}Xgd=UY+hB|hxRhoc)hSQnb$f=Y6uD-;!b!q0SvjA*#|28P9$?d5D$CfoQBAo zdvq3s!WiVYu_~hedD%Pmd@*&&l&>I#`B?Z{{en5B`or+J^s3Np#yK*CpN<%5J zm7kS|L3_2~>qXb@V6HKcDB>-mzrZ3J<<4m*Fb7AC!z8%OFllmHP7A+)-G2Ddfk36`30qMtv`HRT}C1`d^#%nXqu%l&SKvLnxzp!LsCxyjvDwp z7Sl{j=D@RcV@7C3NRGq0hJuP;A~pYsg%NzDczC+d1V%OiTpJ4^R2)eut8SQFhaO{U zn3`epDZw1N7657G`4;?I z?3TEOhC z)C24159*(6uROn>iBE4;ufHL_0{j1F63buf`UY?RmLsTZPV@C{^&Q>OQVTa*C0kfC z4#Ov8LRL}PpJ9&~<6h)ZZSeXu#=I_I-E>cG*IW|`3*TI3mL4R3UE*zuJ0l9Z%%a&#Y1>jAvTCPMYXZ_Y!h{|qe6W`8m zqtqc9Fnnm{mA=TGsA7ajmE}`cG>g~kw7y*?lgIAvhg}B)O9{5k6=nm*BY~J(66>oH zI($>(20OZ@L23&Hx^skaDp6QQ_clXjTbRp`WCBfqIEd&V3)=k z^kYIK>D{Mpe1$bV4Rh)<<%&O!yLTK@=W8^ja@SkqmFbwL!iz9nufJ!zMPwH`i|;#H z+XDqbgrhz*zi_%`CYP~NVGoIx)=v3Nzn)WRvC%&`fR-S=L4@zP!%&67bTR9dMF<14 zSS1@i>u#Xd2e4B=s^}?8-`CV$S%{P0l2uO=FjZ$w$z%PrHJybZeC25gdgn-OWO2xQ z*pQ433v~l;9Gjzn?1#`G!JO;=*u2K@Fg}1Z!?DhGq^)065@%<&D5L^g|BA~+BZ=GT7SxyAoDocD6uaS~4`sUM*jeagB^_*s+3*uv1oA0} z6`y@h&xU6Q>bF|bVrs~8=;(}r`T{*f)d@ui?mC|f{zUAXe zIu-Ra&(Z*`T@>&;BeRGKw3RZKW{0bFwQnYl;t?CAImpY%6EvoxTY1EF|o>L zfJvu%(8l^vB;U7r3}cw5?_Bv%{F}+(q(;18wSq#4L)r3A`Q%|Ir&)*F|IqCgtb1rU{g-S86v0DX@&{2Uu>=bZj!MADFN03py zVdX!?uRc(}nJ+5Qbtu>Z3e=tPI*ua9n^D$?1viYFAOKSpc zCr{Zj;P_G0wy6OmK@HGUi#!bdc#7(q+Cr`?vDK*rDi^DhcUiG&+bPaj=`JkWt|Zmu za~y**Mc}_n0oVY%p$S%-kA*#?pV<~(VtIgWHOnBMK9Y&(?#H-q4an@=xAnTr43r5xfi;O?FaeBC}>XMSg6r@-4?di!7AFY%( z4s>Xli03cm&^P)~lvEqx9_f)5g1-nE7g9P2I9Eslc9~JgMPpNh=%#-5B#RQ8RYA@9 zr;`~5FDXc}r$i0v8Q^Cj+3|v3t=_cd&6T21noXrv@6>;g<2&CyZG6wVGr&tQQG#*B&Kw zFQo2F3Qkat#*(CQnH_Q|)d{=H8)XP39Pr=E2;kX%gTrgZ+NbQFvwHlp{}WlgLva#? z2PnBhfjf%yw{<2p9liB_=|i*#X1-(T9*?}&GC#e#MTv9V&*XP;a@XR^Fd7t+>>OY0SYSsOrzc+0aF7*7g%dXs(~|bdjR5xs|Kcx=2um z*NTzVU!)jNbBe;IX}F+0x+T$BZP@(fMXKNIi)UciUeRo{DQ3})_jrlY8a!$C`(jAK zGx*dM_yKUMJdL&rIBXgTktCNq+(}A-0c>fa{>m#jfNQg8i$3Y?S3Or8=xg(8=6nquGqLQ;R1*IQ>wxi}CKYB%Ia6cAhL!+T_htXAz?uoxfh*dkBZg~>* z8Y&74TS!h(@cq{H;*7uTn#Wh|F?`u(2BT8ks*n)OW^5|=DI`=!pL1GMC-a+-{_MQp zGcOcXYd6dolns|HOi0xnVhd?M12)?dnc3JE{3JnCc+sIewQw$bAx1Asfp#w}Gst~; zJ56jVE4`+@{5H57g4YgipSUHbCR`3#kS$RnG?haLCsxsf9qJni0McQ zMGwtIf%Hi0EcADD{J?;ihZFdwjqBD5iX%pVC1w1=W2N$w3;RE3JEw4wRYfk~4x(V1 zXig_It=<%cgJrWPT3L&HHeo(3Es>;H05m{<<_jmR#be!vK(Fu{Vl}8fG2hGVG90J< z8OLK`hmT9Kb92Hr%OZ^428;=pS`Fw@r?#{z`1`Gh`Q1i+@gYlLvnoy1#7%!W2Dz4{ z${Ix5YI!*XZNj3v@bQNV?LryDZ2C8O%n|uN4#7^arF{W|o8kA1n983<5cl{qdLpBf z@4SH+-xK9K&nl+`xkL3QVT--kLUZ(mSe_ui1}o%{NByoccYSFa%(>e9aSiN8ck(#O zPIQjyJXuzuap_=^$>i9oAdal{lE4ZqkFhTvm@Nn_-~fLQpDty0P0)vtrl=aSEjp5-USJS9E>-z={WEm`U0 z?5laSIOF(~w8uaWt|&(-(TP8x{xZn|6nMz5KkmE4k3|by>(9(qhARLd>xyc&Junko zkOGCmuVo{!-;%kCw}<1i)sTi8Uea*sOGOlv@jC zSo{<^MCbCSQGsw?gln`mr0(^?#$*R#m;{q{yw&B>u#U?DL?T2I!{MSLateVLa#T8Q zf_%=vpNSiT5`E3Ris+i|$2)`Dhv-<3Q0DVvO2+&I?uNJ;#i*s*H}ae}nNK41aq+dY zQgIik<^{V$^wHmw<5rO7S($8(XUj~*Oa+enpHfp7LPgj^(h=gNKYEEB%47~!svRI+) zQK2k-r7H6Du1DJ_yuiHzV{noR2ISS*3}{*&kC>(T?*(wDnjJw0FFj{cZQNYQ-WLrw zK-3k#^{W6@WGuI1@7{Pp;I&K2+Mqj%bU3GKf491N=kLf9OPC`uAHwk30n8_zx(mcn z|ImYKv)q@P#?X|EP&gPcw|6npuQH;xl@eS+vVk7Aar~)XDpv6tVWZ9xdbIA6NfuFu zaR4}baD&#aW(8Kk6(OLDpxSjo6pC}e;WPz6VEZaZV9ClR<=$5LFnwajS9Aey^>OhQ zD+3`}ltIc1k5S(NqL;#Tl~YpD$}WWY&5_gG`mP4NEAbulY32G|zT>flY1O_Jmu*qu zMQ}IC&U!5G40{Hq@cE4a9HbOuoyQnob}K3ncA!)+t%8lTYNWZ~sB>2Zb(Cce4wSFD zEb3Q(Izx?t4D}`xDdioi50Mhvx)(mT-rWKegbt=}P1i_?GsKmAUUj9X3prJ7 z8vSV8a2uZYykzo>L6`&%$aGb~eipxPH0Ew2*(@n;SX@SMZ}txy;-gDSBSU6ydCGAe z8p=)g@AvphJMlmRaSdcB&<4e^eUVlw6#71e#p| zU#0ZJEN)JTW6qQfkwwIbE<^QK9RFpz7>?Y?suc&$EA~s?q_?QzJ@?~_&os zZA_c`?Hcnfq4?Jf=>qB9ZpK`Vz96YTo@85F4(m;f@L6A<-~b0$9lak-Slbqt$)T>y z@nb_6>VU6qAy}Tr*7?UNbuu@nooc0QY_l3YPJ_Hdb{>x1vzBhFk(@xLS0lhL{S&y{ z9>*vXb0I3a5eip>PKLI~1s8N}(U0-^d@{)J-sUAC_kh<}h zC@~))YRtLTUKjB9Js@o5T9KBKG0J(+$0Yel3eIPKfGC3wwlWXX!>? z{jFi@ys5{FOP_UR!Uyrl3w!U7jj6rL8ZkX27qg0A7Bxg=L zd>F*Y?MzI7B7O3k8|Hx==n9`V!vE@6y>5MF6uWK9L#E<5E1h45DiMBd%K^vZXFdZh!D3r)u=;wC37=#5zfr2;xa7zhARBAJ>SS*fyVU)t9M6c66d=I zbZo93j8y~uX|s$aQu7P_?Ck4HFMLACei=k8`01c2&Nh6@5dz^Agmf^e6t9p^>aRxF zIjJ-~Ny3=-IM8=>1-B~no5aQxQJtd0%*T{8>?hx}A8K`DzKe<#^Bg$@ceM`xCV4~o zJlU$w`l(`GP!AJ1(u}DTRQY*ecq?#3*us_|9SJdPlt)MJ`4WLi!8T}nb&2I=K4Io5#KTUWH_ylEgO;joUI*Z%b04k*XgInhf^^IBTZ zBf>cQlmlWBX$bki z^r8=&0D9I@$b&L=ml&=-rU?eoTh74oRCBmNyVA| zlk}r31FO=+V2Ig;=PI8M@B}U?r`y6O-W7~AFZ3N&wr+=GvJQ~=B@|nM>a3P5MF)Nr zSu}GBhuQaQHA& z(j%Pd!9fvBeNh*v!U9`cXXrl_bQi1+pvc+6jai$X_8oyMIREV}c*l(~8%q{_`-!xq zVJn(1`Iy)DLAt=*V39#4&|)zw!7(R}f4LUov>G30%aJ;EJrS@w^C&8BKNJF)W$W+-XnHwI(cP&U9FN6jUeY#|N3n(vM9>Lrk2r!kTY{0f3SzsH6r^Ep{LDgXJ zm@!~V-W%%zZ7HvKHr>91h=zRhMJ)ln7rv1iSc5&db<%%SN-0I!()5}^31pdkJ9e%R zeLRfDGQ)}1nL3C)LrOmfY`D-oVEe(E;aa5nDQC64z^|~EySliYFW!y6{R_yv@U3t{ zTHf@Ju-*|EGRv7W#~aZVL?j~Q$5d+}(b^4H>qFp{&fDHDxLYR)yqoNU4BBVHK2j}i z-YnoMsYcgp%R~aYymd!nlw|hCEIl_bEU_dwkB4^Im$*NR8B|4U4?-tkjd5&TB3@Us zSwI37$9;WnLo-zL%(N?qE(tKT7P`={9G|P?^x>P-rF+rFMQON0RyUi>IPx7G{uF-n zoXjYmnOlv?UvR!L=&EN;2Omn&I`G%F23I~g0SOpy>C{_4xMK!Z1s>X-XT9Gj_S`c3 zh1Fc0`P$*BSy7hh*1R>3x&oxiCLDZLkeHo!p2N-{_;{B2e2xY(weRQQr)F&>;kBSb ztc*!F0DR34SVeddxnVn2U;B18#=qFtHi)ARrTB2=Nf?) zWwh-NFOHeakXUb=JFVNYeHSg`huAN<1ch3rU;#aVSP-6Q;mk6Tun$Vc=cV}7t1A7> zUKfcgn^gK}@ara`TzR|!QD7@*mErZdZ55}-&Bp3Uh4|Cg?9johkwv{Jye{+1lE4<0 zcSlPZtC2qSw+3)ksNeYX+%ItHZ2d9ESY8cu%dAKyc5PM5w9;*_8S2Du`se8!llM3C z&70=+RHwsZN!QeqX~pfzbqtb>b25B^nXg*bbPB9V zo*y1f+CCH6TvwPN?llk+Y_RYO;K>WU3XQ;4w|0Nsbt`fFmG;jKTz4(09n8@<--v z_3YtRLZcD1p<8fd8Rn65tOQmEM+(#(9XAFj3&^IvZCzDo%RHU<+laf~z}5uvC@!F5 z!>h#MgPy3TNN_!S=bIhejIwZHB~9RjomE*VrH{T;ndFty774+;8u!jUpD-R$ofIPj zx1(k&j0<~6BbFyaB&QW>o-zc=F-25K za&Ff`$0Xb$(qh7QnHx~**}*`JEC{5!{A`i?+4>A+S6GI+x}@ug)Zj$6=DZC85<7n? z7|Yk6e)Q{rV7@1ceG_7wXWj7^+2A?sVPQnO?!69EvSOy^;Zl9O^=?}0 z@7QOubB(JrHTCl11e2hha;ey$u2l%eX6@7IAK%1bpdh~qAe)24E8zyAi61UL&QfC93J&9kssn?VRZQb#z+7iDs=?Yv{pGZ<^s zG`TAfUSw4avAyX5nD9J~x)h)&Oq>Mu*0IS^Z=iQ-GM1E-QWOgC_Mj$|_7GnTXo27G z$*y?a-@EMalWgwuiIsh^O--1|IdP{rca?wA)^=xIcRSay+j#6de56E~uX_qTa{BV% zASU9X1Uy(TI((?4Q9DXPzZZkvyzq7u=kzsXqhfaFlbZE9au>W$@a{t?_yCIT-TI@@ zYi>YA3bSQC=(XaOVMDt-w%rv-+jIC(9Zb3QCQj~PzOU!Oc1jGu!n>G%4`v#d8Ru`F zQAsC6qb5HQ|7)roiS~7U|8GZt74U!J9sZwG*~;`E1K7pg;y<+o)s?2~gPD-JPiZx> zWWD>l3n|30)O%h@>9kBZAyhYGRf&Ikgb1u9yB4h3d096Wa^fA&U2nN$Zo^69M4{-8 z{;Zj1lS`w_!i6*y-q|zYp-K^LPKPc-*#L~BB`g1tlXaTvL08ibN~ zWcp_AW$;7NJgmd#9KSq`d8JrWFSC0F7rav?AyY}{u(@7JDMPCKU02P+$7HHq7}Zu}8n34sCvQX}|J!*VLW937nhd&5-?wvFQ9 z=nHS32?p`Hc7~>)iijAodIgah*v$n^ES;%hk~F@eb-eMS`(N)_K7Lch#{Qmpn8808 zkAAoBMm<`$*Z71*zs=v^=&^J)Eyl)N1~Hx=|ILPtHD#xgQLlm2$pXF}Kp_bdm!k@J zvn|}1$G``{vrKj+A-^4Ws{m@S-<-33ExvS(MSWw z@#p5{9nVE%vYwM?~*d0-PR5aLq4-~~!I z9`I_PwE7rZ*ADD!)Xk)L$De>kL53GJm!8PPlup&B%!&e~wngUQQ@@+! z%MLd%ElwLYEw1`3n>T&yICW`A?F>oNzUa8i&M68kYLEa{CKWG6-&;OH38J5wJZh;K zgo+Fpk$I;ewYW%PUiNSVZOWZP{TK5f5jsf`_FzhDW7K~cM6|bABQJ zd$y)a;G}IrpFSxq6dV+A-1sdgri!YEq)-A=RuY9nnZH%Pk>hoxX^-QaUBp}!2mL`K z($5viL^DCNC$q+FKz^C9NI}iAgBnHai2Fe!ituem#bZ1`OaVL*R5AQTZ_Oux3TlyK zO5j>`WMe6=%9EuCVqAw#9b{FkRGNNNaGa3PE=naV{i@v(fP*6Eov9=!oyKZidTn#U}EJU9iD%r17lwdB?^qKF?9%iKd(}t;XJ!Oc`=rs{C(yl zGDH(!1qKfV49?JHzQq*9dt)*J;|0&|!vclAz8W_=4~c|2bFwnIsy&kS@`gLKc+sw; zc+TlroJv0WFj(_zP_V}Y#ifO?HeHDv5s4o1=5d}cd=}8vJzb?XTEX>j z$DDid+I0vOX6?3M#*TEv@NvtCSA0hj-WKI1)KcK_?k-MVXu#;Et;a{HK(#u%9V$;} zx(*yv7REBlRcrMtj!?~K_tHhg6(Tgg!0u<|6O<%}gY0Uk%|bs~ zuNU9%=svr|U%KFN`W1FGkd<%GH3&ma^6Hpsco{^;@7p&lIvR^w1NO;?v|-9Pvv)Rh zVC#jMf;KuUdNg;aZC6#5#N6lNGdXq6(sG35`WFQi24P7f4a}O_ZEJA$5{LVs4!Q)l z@<^fbV-Da7VEp8Pb!3rq)x+t+{?*APw0VC?qv_sZC8Zwlh`nDDYNYk<&6|UgbE6*w z)7lh=8f=fB`gYwc9=(ln5CDNwae_-(&10fnwuX#GYF=ZfzU4pJobPD2<5R-VT~BYrqYZy^DvS+MJ@b@Q{moN9ayI6THZbj8g&@?Z z$_?HrUPY`xWpTTy#^%w*5jbe}&WIzCC-1>4f>9x3+kc3Nrc_Xs!q_h6_3MPKV;B>y zEks7?1Zq&#`6JZTxxWbZoJd+kAz=l+ zGRC2&S_$OAU5ydmERQLBx*qF6&0-Be6ljJxg^WY#QY!6L)P3F^{K0FR<|%IINO9s5 zI`Ot1%Dos@+|DRwZ%wVR^R7F-ciwh6HC7I6u z+sef^W``>O1nTg}(_vq?rLQ14hL10GL8yO(^{(Oh95Z$8jVCO*#8>V5;RFD%jwb%{ zOX7v_rewA8efX0Ps0+mE*z9=ct+bzBX4&9jZ5YGrrCw8AaFcHx8}|qPujx%~eN<}( z9uSbX(SNd&t2sDYnf|wS@*}-%XI#$I-dEM9Vm@N_qSWLo+vVT*o8+?1#b<3|>l=~P z>VdWiSHzuJ!4kTu=6WrN4X+*dtZ$PZKFPjG-w1PpZ&uvvH680ZDYC?f0ib(dey6Uq zg1vh58u0}DUMTX>A%d|OGBuNBin)JOTgoYP7fHNXP($UkYcYJtqbYCw^Cu*IY73*m zXFUlTd{}9!&PBw3O%~B87OsCxiBz5?Pz#Ld{2{&|z^t4+Jr0r0RlxMQoG%Jd1W)$S zqRCKLj_uUFe|Uezoh9JU#PF&pyg!~E85o&AJ9*n90}y<7SQvfoGTnV6?yie^mu*Tu zC*68OJSF*m&tP*C+Qlm>A}G87R<(-&_j$dKnz#7bxiQSn&s2l6d+4&B1ctSJQYD^Y zxU-P+kC^kzM!Iqb+awn1K2X%ZgQv}FfAHbQqR6d9lMCXzk9^ku;2ytTpRT?!RUGlj zG@YR%pH??w-afMJ2faL(n~+3rty}bI-RbRVPJJavD^FSvTw6)fDL%!Vsl)WzcHWgg zQy+YrbW)W?>r&i0WJq;uU(TW?p19<8J>gT>>rYYz+mZ9DO8#0O14gNkGIBNYFmw3! zYPrxZ`1PC7GZWmElss)qW`r~uzW!^cgg|}54n{RRsyp!60vqNwJ1|^D;GX{mwUO&I ziG;mviz%m;=(p~N*=vE;xpafL{c7@ygyFLxTo>z0-GR2!R6b7A8V z&*hn-nH3msN}*`WAQJ_R)nHEX~V1`*M18|5nI8o9~g|0XHgS1s{cN z(3~~bMz9r<#*|Be$p~2X3MyF>VK!&bkSi%DvD0?L21XW^6XMTR9prmA(nhlF1#y)* zs)2OcI`OPVl+>okpVhp|*q{7{*a$@6;krZw6nw;Od&lXOEh8QLk88n#HG(+0jo&^%T1Szsd(s5kSS*I+8 z$c#t~>6mdb3i=xiwbL~5qo?gcD&rady!B2;5q9LdJg;x4QroOwagkVXm$q)X4-3&l z?XK7cK49W$*54EH=A;>58+I|QJBI$H>cVG!`a%ZAA*ENE5L-e$puG{VWLfunsb){) zaBidGEQ!mm*a)8+8>TEp?bvzDFjD_a@%JfxZ9hr7;V>H(mxfXt28{uEUhBcpcYyGU z&fA7j(WB!2EkxSHPz9m9W_yxU;HqbY)%lIWQnM_w`~^ays)#Unl6yVfu}|g%U1(-6 z(fZ6g*RhIzfr4{9P!1RtBdv?TJU{02@B_hp^%y98BxzD+k{&t=se68fXl^MVS@aUe zsKusr4JO+I!uLO|93U_dY5%BfO6jawD#kNw!&xAo3z|32AF3j16n+5E-|v7k3%G?B zD^~nhjFIY>XHY|?Q$8?wu?tY_=;zaHJu4A}L(tepL{4D;w|a<(kVvk%5`_W&f~Yg8 z^`^--QZux5Qzlb#U{)%}9vYG2q{$?>Qc5^OBWxwyR_AjE=jQ+dhQO+`Q8QI>fmiYP z{bxps<6&=gzg-8Hv^BK#cv!4$n`>K>8A>Z8@^V{86PWkP9F2Id)Dk}p2fSfnt=Q#t zF;iX!ck7t`0uJleNF6tU-wjVE3AoQ|!?OQ=ENw1fnci)~82V0;c;<8JvdIZ@IVbQ} zjZ{=JMPRk9h;a0JA;n9@H27R0zhqt9g_Pfc0=&RspXmM4=vm_CD7g`3bC4(NJi)L= zO7d_WDUgQ)4xcGImJ-7hkmFQiwB{BP^f)MmKg*D;`D1#+i@=3LAo(&{=btXwdwAsAkA zMAfuxctTu^`xJrI8$(9ah(nK;GGu>5NvO-Pj*x#f{E+X6sy&jd2vmoWc*kIEho?;Q z`{3n^W2rL1Y2vX~NE#|fOr?!X!rqkO{Dw3NSRkiq)`IO61;~TxLF}^mT;)4?$bgQf{7=o zjZF|>eL(PFz%!)p8_vMAC`+YwcUGxMoU4?#(w5xQ*^0npM0ZFM|4%H}IGQ9(=KlrH z77%ihX7x$!(+-Jwd-xOa6xnx+{OWQr{{b7YY}Y;Y&|SyY@415tVa%^XtjOSX-V`$Z z8gZaT=F7uJ@4}JDHM~$ZR3^%vOB~^OGCw+R355qL`*=5$PV+*ymGNiA^f@fr+tAE` zy96hX0(9P5m?$gBy-~Hu_cK!<6FY zGz6nK_!5eD$Z=~}4s@;O3fu>+zSw?uU&MM;3=>a^MZukqmJ9Z)FA}!2fqw46App`T@uRm zG)fHlRz@KC+Jbd2XFChCVN>782`lCGkvKy!Isy%2Qw{L)RYI!=cE{8>eiF+I;=Qa; z`rACoEG8Wu2`5WYO_8OAcf=N_&bde0k%Z-aBP%0hN5qiw*%+ON-{9uYd#Q<$+%9VW zwHS-gX4Jh6E7?wXMd_BkDj<5Sg?TL{6rR1t7m~iB)Dwy~-54pLB42xxi>ei41=yM( zb?#xM<0E|xH!&t*)X>~J$ofy~B#6_P2s zIg!Ue+QXS}fIUanlw$=rCsi3n4)`yfsNoi*+Ui`thm`&+fXyyNW4h(Q!#-jz&23XW z7T?_seLeS44&lUhPg=1?JZN!kqpmitqxj$g524(1#s->5>7LLKz7_6J9-u zD5H%SQmnRGQ(@Z9FEx22_E_L$GVlj!sa19}_8_#e^xfSA`hMrgVYrM7 zxfKc%%x}o@8AjyQSb&C35n9lxQY?CN#{z7OjuRg@3O(A0-Vywu4|9mBc!NL1YtYxV zig9@|z-)~!#-3k1O+I=A86SyBA;OGs* z#!{p0p93eV!#3uOtRH2IH#o}*talj92QZMR@Dj5d_gN{bRe-v#>463c_MN42tnF>A z%V(L+21QEgYC-4EeLz+#ij4+utd^FQfDI5eIL*QFn*sGrbW?t%%;gOZ_h?!-`G&?; zdKpH7hdul1k*LUf2|MZ41X}PU`+AW}-GCVGczwrv+J@~gt}S)YCV%FI3Cv^(tCn)3 zb31>?1ruNZz?*je$l;UpHAH1Y$!o8!GKkjZ>yOA-Wco$9n$A~-9>O_L^=Q!SNK%Ad zRlg7?MqoF~bv}!ougf3fsdHQsD=L3A@Mn*aC#Tu|suWuLi z*#t`~w`L}uy+5AL1BLYZt>zX$D80BOIk5twn|jv|ct0R8rY+aBYe4fuowd z=1gBQL;tG@QkKDJH_rby-v8NU5HF|>+pcoa1e`xhJT(ePU!I#20V)w+Y)*A1e_U#a zwoxDt2{nSiI6QgQ;1oNszm0N!zbQu3Xo@y%-F*{(ylmz!USezR>Ki&VU!I4boN(&k zvZqzu)^Qf?q+}t}1v{*rx<8pxR;8}GdF;gZ#`p$uAH@gK*QiAK*3ci*1uJDb1dRDq zdNyLqM~R5VJ0MTCg$C4QV7Jup8)xp2Sv2={WV9j6Za|Cw%e;)5 zX&7aGmQ#Q*eg>UwByJ_gaRJ(O2A?K()h@>pPp!T-4GxK0(J2}gt>>q^M#pu`fA1r; z7q0H_=QX)0^zuB`bi{Dg_Bw;h3lR3|g9CT=Muu>nUUSitvLr-b73gWR>K{k|)oueh zRjY~9!(>d$$$9)lnH_YM>SSvnZO*HYxXiq{ZRmQv2F8Qc^VU?+A7Y3yLz_ba!cT^O z@ADEVYjp!~e*(JkGL{GDK^6(Wr4}Ni2F*xm4s3eQ#b>w35i?E}8@#x8r>? zeHY4C*bjL2mQk&8xgn-MEPHx;ntYizc=-jf+#DPY;VZ9FLFXC47wTuWKD?joT-TH& z-apkayhdF}ZjrR_Axib(&*1yKCa2|I5A``OImC%p5gl9?9Qp!H^{py!++!SfNVg`iXD^ZEaA@8PA}qGWJSyH*F$~Tvn1^xnWAcq2cl?O__nQirZd| zW{`w>7HLI?k5)OneygdL*4*m~y(TR-&Braq{Hl=h`dW?J3170~5FNP6)5C#&WCZUa zT&kT}=veALL<1=oyu7Hu?#~R~lS(8j6z-sqFb!|lHS1P8M_V3lPO506 zJFY{AxH*(A*(FJS5|V;_qLim>64FPClRMwxJ^xe_J3aR)plcyN=Tnchy5cZo`mV9> zDA(C~mNKO?Lz9-sQ9IsE)|v7tz>Rj8mZVS2Fbe^s-AODS2~&5`6gNVaVKx8RZ`C@2 zXf?c0+`w;VDgF+^GpcRL+r%vNJmFbN6dj04{8y@B#8NxVDm!Tgy{t%Rkfq|6a2IKI zn5Y@6Z<7M_7srG7DpMc9Kl^JQ?_@A&F^b|UU^5aa62NFLs;glVD_z&)(nX1WrXSDX zOc!}2TdBwk8b8h)dTzUXMx7$;2UK=xXFQV@$ffo=w-%44&$JfLNv6l+kVCLdrlrpD zj7hY`Uu0%10a6a15xh`{>Jq+|+EfB=HD=mo+9{mw9&3K2*K|k$TIOn+a`;8wx=;a5 z3U$bT=y6Z98_0N%B%r1_A0XFYzj{%XG~~#$k`@g60& zqZs?{k`%$S`FG$Vq3-~Xy;9jCuZu%l{EpG*{?v5&Pq;JZ+Ch{;sE&myp<(-vN(Mlro`ORD$Rq0fj;^TpP43BdWufOk0Jg$At@kX+K(W+EQ55b<7jlBQeG!+m3 zE`E+=-H(YC&?-ndLTeZCFZgGLQIc-J^iEo_Y4Cd|$`%t1&e8{=67>#e2$0A1O&u)) z%0C>oD@qd$%F@7H))}+a8PR}KdA2K}=(E)mZ+20NMB<~udaPD2rwQ{fdDjj>9zt=r zitYqX>SrPYiA~C`nlNPtt@hk5l08udY%%u{gG zZO&?1SZhgP&E|HEvz%!mQ9poKv>PM-L8NLN=rA7UQ4AwJ3_d>E^gn7Z)?7ePgmC@A z3aE}1o5>hlO8}rKdsiZWoNii@uv8E9Vc&>iPgyrib1^cqnBz*T^eAWKn)AcH zX3n_k-!ouTA(xSlN(8Y{N3ajXpF2a5>OGD;aS>Hz$;l=Qkn2P}Nm~K`K+Nc4t-Hd_ zT!TLkkB9$&CziD|j}BXJHpWhJP3twpk55MjUYIJC&8ZO3hiVrbEYp8T!7ojz6POUXTgqVkTW7KNZ&INXqjX;tRX6bHiU)Lh+vNH zVlwUJEGYe4A*0|untQd!)%4Q%_^>P36TZE5e@kJS>zveVG^rU6=fCtf>DD;obbtK2 zp1z&^O>nHnZCm@*H?e}&?Dc>i{!a0ftOGk+!^>m+Rw@Z!m`P0pKrP2R|D*<;4guaq9?ukoAGL0MOa8jVk|qRx zqGu+@w;U);L&<)$m^e!ku$XFWK4KP+nBIfQEQ+fHI7y<-4Gi1cxur)UHZwah#Z(v2j7 znQ7r*_tV%v8_=$p^{-udYS=3GOP^HZ02Q+I5rP(s@=Ew_NhTao{##cHD{=b1&QB~- z!}4~g6gaqd2#2kT`)zB9!Dt|c8z$CY`pyp$xu?vUYbiHrdJ+YTdTLG=&3JY>X&ov+ zE{i@4F5A#^OwGz&V-tfoVD;?=_<2?Q+a0niIDQM3gN1UH@NY2LGKMJ$fP)xir=v`! zK|ce$v!e;hYBIP^xZ8cAS_3UpwXqEg)*XR*zSuT=+dwjDP6|%=5UMDj=4chZzj24K zXlcDWx5?ch`by-;ksva~(u`|&*ag2h9K?pyk?15e*O<;gsp{EL>R)1o=vhNX zUrK<^o)<-aqO0{UWU)ItciitNxqII3OC?f5?WwPu64n25=y2ha1F+=i{~EMc{kK|Z z*kKFCCid%5D`#Of1&$Yonqh&rP0vJ0L+ChvUZ?yVU@Isydq>D?w6f}rCs0@wOYW-y zxk?m|GE+&Ot%AB>+V&!FmFL6t=hx~sAO50s39b(haj=|2QCxkq!WNSx zLgih6K}vQeMBPjOxO`GhPKmu(Oy*J$!(IXz>IoTrt-d4h?t`|E$HcUNPLyC5IL_G^ zsAC{+LN=5_l{F>$nCE`+zK5%^Ij$HuU!Jdoj(XG-#=_@584AIm z-5OYgVCE{440BC9@7s(`xcjpj{mu}s>|PEvt7}a`#RTDFY|?3AdSeV*Gs~ks-flj^YX;e@m#*!l-VZ< zSzqG-UWM48$wC0lNSgcX7Y#%q?etB94~n@YO^p;jz4M$hmR>^>+RTTqOi@?LhJQ^` z!yj7!H}sVrVhy=8Bo?9+OD=m*NyrAe%C(!hVP2WErSS{N>Y2IRfIs^XAW35J3|yQ0 z{?261Pt*l>GG*XZw=~q&T|MvWVd_qi$Ml&PLA0Fb*5R_u_qe~xojuDEt&&sMnkTOG z#ALgth;_ZIH{&Vhr0iB`l7rvoR>UUc)r^obh~HC8gk^Mi}l zt*>_X@){{n2D8l*LtQLeP}T3$B*P~Pqgn?C(%R}Z3Mz-I)!u>wSP~A(m2cfLWW_=b zg4+{V@oVYabmbe{7uQZ4;VQ|-iK~sHbTPR+AMw`>t+*kzVX4Pb_W^`0A7R2Dk+UW; zA*0Et!hmySvwSxg&}nj3l+@z3pXS}KhXFxFehC)=(5U+h`9abNV|_FEA^Rw)hRK*b zrMRR}&&1=_QhMSHlb|{rrhhA_>w)2i#mFyHDrM^Aj^C|H8l{K>Ce^2s2@lyd6%$)e zX+tnmQH637qsk3FWGV&NK0rB9E~11$h253i1Ch%_r6A+HJf1 zY&&HsQonN?wgjqEMKrw&im#ZB#EKY2NfG=VIq26ukP$5G}DBWLZ2ZkGNygYiP< z1tf^oOD&hE^8pDAqH%L#NR4GBn8Vy}oQy3)OPG>5=}cP?6(%EmY~e*I3Mvcpj8U0% z-KE>FIYw}Qx7BDtfF%;AM$WDg#qUYyWU>8I{gf>FV@yZGp-Em?eI5G3a^KCo;d5vA zu|Fs8bCn7vQ5|lt{#VD8=Ycmt-K(jplh=KugcqLW*4CO=ExF-=7F=<)E3*ruSIfS> zgTKlG_5IuA7Rw=ky!PVx?;%BJ88liN{LWtY*bro>UKyY6BUODHRH$4PqV_rsaO$BA35jjgDsZ3jBMDr6}bv4}`!c+WMBl{>BJ6jZ9 zdrmeP_{Lo+E~NK8)nj$O3RpQuK_>^d6w$AqFUK@p9x)oUD-~+rKS~p@Sy1LG7V-xi(Zu4;)@aV zVm0c3nIMEpD5wg{TCOyjfL1M33fKWSH6D8)AFQ`Fwx5HaQp zN&zvh=b~cSkUV85r%B|@x%^?89xopHFsX9g**GdbaJr-8A;xRkVv6d$ zgL>=4W*(uErwaVOerbIC|Ij0z&m(hVM-*ExcLNRL8V2Jrl&h2JQRxoDGATBG7_;nX zVo5;vWB#pofM0HExI7Q2tK*}wQl>3a+Kak{IPNv2vusNy?LZjv2LXLsv+vN?H_nq| z^zZf^4XW+~f89vePV|2 z{4PgacV}(rm>c<1lA5R8d#dE>0>Dmi5fk1D$;$l8#FW_`WFa(>vWKgsgR)Ts!= zSO^`u&+?MuqjJVDVX}Q@>np2{5&ebs??ViKFUX$J)b;t*SN{1l%_r~;H%L>F%IbE9 z$mRy3|M1ovqP$FEeAZjJ>T+!d#I;v=s#j~Wx;o`GbmspBP!@iPhDiJZD0O~AehL0p z?3ALj-T!e!|F`?ITW#B6Q3R!DR*|~@Pr0F{jGqhxvkfUja6+w+Zqe=H=%(&JD+B&fPamFDY8zmM~R z>k+C=XhW>Mun*$=`feVy&{4N$LITlM0!YGuorF0e6!)J`>3Q*qn!5m|`JgR_SLTO1 zBp=uSF)LB>=oB*{Lp*c$y9D|KN(gT5Q-73W&Gku z1$Y@Vm8`66bKthbCO$wMD@K$;r)9gL+NV}^nh~L@3R963{WY;t#h8psVRsREJC{)` zMNjCrev!ELIGsaGH!w>p0@g-bz}}uC66})NZn{U}J>;3V#Z-)8Y-XQetqXk!zzp3w z@R*VYc|CC&2p4|LR8!L;OG3DN&5(LgE&BO4JN{7X&{JWFdN-Ny+mtfN#|l^{Hb@7a zI`<25ua$YabtE|`xc=ZvmA~4fP^cA+#j0>jdoWmSt*_@#zdjCE8%VPRUR&HIYn-1$ zCv;|TWzrRq^Ygi)KTCKz1tOrjK#M84F&-S|cz@q|Py!YBnWOij+vhqr`xL|uQl}M}h2fNu$h1cFOhG0tGa(C+|YU5oIiZ zjz$C^J%CAGNqs|!CcXQkQb%xSYS$C~FjT9Z8Ca8U!GU9GnxLk?n@MeR6%I5egP8EN zKNwmv(XEN&WA20zrr_+TM6~l5Z zdLGyywM=H-Idh+Lc+5HXuJEhAReD-XCGT$JPjzV0apNs5jo}aFt-8}iAO}}o*7!%Z+LSFS9C}VoGXNZNGMzb%%$34W4EX?Nv`lJ4V z3jW)A?L0yVfM~lIoX61!>rvrD^B%ELeGkW0c!s;z4X^n#>_Hr9518F9>s<`Fp4(`q zTr8HmK(Hoi5I0tWzz(c5>5V2OgHiYH4sAf+Lm*klZ<7?I$E+fCdb}2|fW=!09U;XB zE}Z4SzN1{q=$)T7`#kd3UX@0bU#X)vJ+m-E0aC{0!-=+Pp=MfV>$bF1>55#fM59Rl zjybpwm`268C&0VIo!)wXrSNbDnEm~7@1ykWN32oX!e>=TF-YYv4G~kA%hE+D$vr&^ z=3Xlcs^rnl-}}sX<-}k*%S5|aeLo}0pu0gOh)8#fWf==BpY*`#)ot`0))ALOIj6&e zmx-mc>7YuuXm{lBV2v3Q2Vj(N^`klRU6c{k81mUSZvu5>46#O%D#Dyq7!2=!p$VT- zR4~M_+p%d@`8nx{j;}%aS3lGoRwr~a`d%G3E`_fqF!%b`I%W`^%>#9sr{o?$p=Q(t ziC+5lM@NbE91B9d(V^%wp^^QsPex2^jw7dJhpmAO8!Eb9ZEmg{T-a(4XxC-BU4Mea zX^vx*(Ctko%3Z?Qd7#Vk7DHwOS?tIM1nK95Yv*2ZSIRj;rGvqTW zI~yufvn_cDD}!t)f587w4KSLtI1NgYAg-mVuiap<_?52zINS6XnP$l`Vg zG~?Es_lZ_JwtaRm2lU&?`dJfT5gZ2G;x zuF_LXR5G-!jgbm1Oxn*Nz#Q&Fp%yD_t*J}2wuh=LfK^yl{Zn%SVwVNQwDcYTV=3l4 z-oYq^j6R|NXN`-kC|~XHy8)&G`k#Oa2DY|Nj!q8xcK_{~j$Z1Q0b)P^nDgu{$U#FA z2qYuI5)=XjCIAc^l{Poc7dN-$O!m9C{Y3Vi3c#{8d0xqGZQMM+(%KG^){}y$=?anT z;fFa_2y5<_)^SeWjTm`1D;k6iIvc)xLzLy=WpXg5xhr2?7YOAYx@t$qwDh)NG>onc}c@wZWqJoVto;jd|oH&Bo+>F^DSMX3@8=iW(<{ zaTbodAi%807-6y)PsHJ;r8)=}-Dz4*q^;RZVvC)}`T(|2@b=k`_x@k#_y2E#DE9xV zxx}2D?Ee4YhrS{^y%7Tg01W?%Il}+^Qhq!0|MrVaE8p5K(j$E3^d1C}vhzzX62{3% zrc0m?&6y!mU@1_C^4vF4lRoAmP5B^5C1$;` z1Sbpw1U)s!N8k?fyjr9As%(6295nB#w*=oH;R_u_U^nbr>lXwgQgmg3(4y3Tyvw%Aml3{S}Mp0L~|@2S;TzI4MqaoiQPS}(7x z1Vvj7T3e(XA&p(*wv!pMZozB@SyuF@N%YuIEZUiLk!ux67?DiHWMm=Pk~Ji(vM_bv zz{o66{jdvv=A84R=maIQr_svF7u2vUDRnsJMS5@1*!=b6&F+Nfc6+mV2D>&+3xU<@ zf!YnOJ6{=zbdPXiA&4q9ZHxHI7itr^3knB6EQ5@?f7E4|M?DtEWZ85% By+Crt@ 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

4+M+NUe#mLP(r$xLu&u%&&jL|)Mm47D z2J`IK@#P2c1C4JZZlZ%x619h2RZ?H1Fo=31e2Y(e{f#e|_LAg_)aqi-!*RE-p@k(~f@3yL^#c9H& z8>b-N z@(>G?zEZG3u=$*h1@K*wyrWU_Eua7?rjIxsF{sB11)*h?g*MX*0nM@C_Nl!o5ERx; zQ~O+5%kAquA(apvBzdpiql`R^Pj?rKUCc%g!}{DqSmdcYiGzdQ_Igmrh>Teo=$=u{ zMs!yUgw&u^gWK6N$K5{@2=n8X$sjl=zVvdsCS{}1$9j}nOp53rGcfk)#E778xrGz0 z&5=w`QiZq@L^5+^iAxEZny#)A^4WjqBt!6i8qx2U>fW_O-`tszT`(14q4Fph9IUyI9Vly_5HaWmmO->&lRO5l53u}9#S)eYc|dz9}%5+-9& z+aH-V{Pn-b-m9;M6}Hcoxpx{>1&u6#)}-FeH_;PT#yC+@g!^&(w329 zZ$w}2Q_W8NL102Iyc4N-&ZkhP8x$jY$wQbQ@6OD3se`4PxW#V?3gFx#1YJ4lcJl_6 zve0Y(vpB;JwMn$$qst#@J4|)!_m8z1`sWy5LU76=uQeFN@N3GehF3eaEyODFI$F)1Qj@KR{n0SK98kCvwk!j}TR zY79wHm?<%t7#mY6bn)O$i<7(Q00W>>S!#&qM&ixnkiCT}cRalcOAae={f+ViiF9S% zEk|cziZmI+a_FH`eXvpxVzWDqTJi66I`qlOB^n;US2kG$!*v?6V5O z;Q(j4zC+JRPD9izQ<|rbB|G9tB}YYUpXysJgMumM={L@Bvm7MP)@CY}UP&Xu*IaM}mnf{Yryv{&A0uBLRh_S=R?lC{GjR2wTO!mw4o z=icM-nAsXm$$M$D-9On)kH?*Go{vyYG?!P@E_F5WzmxNdMDKcj?iat_KoU2P(24xI zi7B;{(u4X9^IQ^^!28wdwngnKP+KkjktZfD63kOZvqJsUqdxtU0jw6}MdkQSld4JR zq@sV6Ka0kQ`q30fw5Kt_f!#+N$*ks15xQC^F7vHSYN&6%a2ZV|J$O@I1Ho{$A8dwZ z)B_f&lfT%EWFLNxXPrMgO%XJ$t8f0GlpHqS^_De>GM}@85nnE{JN}KylTd7ptuxbl znWY`0kHkAfEaTJ=Z<6U@2u)W>TSJnjGmp=3>98lDkzuMr$j2-iV=ED-ifE+Br~pQX z^^u57&6@7bsqgvQ#t-cjzw3$cy=Z}Md=1V_h|%!Ey}INx;D zhxDGN&R~ATIBXU;!qo+a!dfD|GGT-^lHiEbv3`0Djl5I9CX^vxOOby3`nC*yNiD43 zNzMiHl<+(@4J>b;Fw+}(Nv*EESI=nujI(hL3)i>$Il$1P+@1+eMxVT!vb{Oq;o%Xnn@I)V3vGymY&hSm( zSGQfI4s4qbI!pGVoGOGeE%5TKsO~+ZqL^m>jZi4|!aO#D+Ln7I*97{DmZS65aS!uw zv)EX6uE1C#@-r@XrnnBEav8jHUN(LZM)n>%y9!`$uei%Sp9%RZe50rZl97uFZYWNm z+SNA4D0i9Pryu%Y^?e3`5j#6HZu1>g5EIOD-uU2s7&VsaGMC#yLWAHFn_86$y8Vez zkv;OzjN&wbfX*p2O!|QE>Vlx)x@@U@ZZ91Mi8LZ2J-N^QYYGpvHZb4BKrqtm&OTTA=kl%EhexChDDB27F# zrCt2+efR;Rp$?AKIBJv53%8`&8|W6ZD7DU7;|$kp;wNO$efh(x^A0fbQtMGaO^@(TMR)$I6qDFO%0lv!`AE9JR%2 zY{|Gn$@NG~g;we~?VK6Q)vHz$&E-+*A&1j`Z*DX`vt5Pwv{^kJ1=sYkE1}6EHIHg# z5IW$i<;(~CnsHXLNVlMs1mb0>#JqQ#&HEuY!~_!RQMbBN{{<9R-URm{o{hj_$NB1T zyNPheG>il6!Nih#a*2Vu(Il^00TzMMv7v%g?y^G~nZV{b&^GS14%g#61f*~ETX@9$ zE2_zZmdS2pAmlcgUgOiS!Q}|wsQR#6{qot(iUs3lf*FuS;ggk7-i|T8tY}qx0H=+9W0meou$mb<026;- zzqp_@b4mkQ2&sb1C*Jy1gyDMTYa7yH(&3ADQ&>V0i=JD&I7zhs1;##cG?cO5DXeLn zKh#)-Mk8Y2M?12>%JJt#JpQ+&wgt#Q{DuIutD| zen#GASMHfscK;Ik_Go*mD{igKM}P~NAoGcgGi^jDFs9{cxo6hjt4$ttIj%2(-Alf- zuox7RCnr@RqYdvi&&G9`$$h0=A=#&;&o9TG5OGIE{^4fSVWS+d;mwkzOlAzSbtJ=L z)mS$h9b4cUya)`GN@Y&o_F*gAEZ>s}3$88D_pw@Y^DdOP9h|El5=?L`ke<&jbM6&% zsv>}o1$n;%$AXg^CU}aTvXF&Uh@lG-12_J13MjLrbb$9{Kz5IaLInn#$R+6=f1b|= z2#1MBz5z=xkBgBicLoy)9?f)#@o`nZOUi#{6bXk>mKw{m0LD)99yZ*HX1bxr;)=a& zWTg2>e<(;)tsQiJ>~IwGj&1^hZ(8e2AR$FUPUZ}XwmHAA?o>nb6`B;Di6f@yn7GWz z!pEvihgL#%wijGB_Ps^Ejc0zl4kI{x zPB(syX!*U1%IiBv>LHE9wgoSfYrMvOc$i;U_c7?YKD0qaD8jM*bE60gZ(LguZ`7I) zsMeKG9m2VLte2xFo^Oq4XoNUf(#i<5GZu_1Ewk#35R^@nZUe8WEoNh{d1Fm)9;WLD ztUxMfQ3K|H^=-FAS;TeKN1q!kXwAm2EZX006xIDN%r%EW z&NJbvy@l0XZG8O*NAb#lV^hMQS3f8f5PLGh)=o;}P|31M`sQ5=nx0o)CGWpl(rM^u zX7o**nZCUekZTXZB__dS(a!J%b;<0kNkyRI+~u@vmA6piLBgr45EU0m z8ttH{r}}NPD(9ilmCMbeE<9yza!pZs6kK=%cb5K0$&A45X%V`h`MT4{82bA5)>FR$ z^tLVlkpDj2($vo)aNSf^39^ey7&O{WiWrFyiP&e=J>|}#Y%#WB5@r?D&{Gmp*$J=c zT0-RtfsaCh0~{#tQS%o_6Q0U-Cll=>Q5&QLt&LU~gLTV_Gzn>}qb3Z`?nWM#A}TsR z!LnKGg~6S}KXh=S$R=2&*P8oE1Wlph%&o+%kTtQPJzGyBj_@v31 zUk0<+H7X+OCHMXsipoE6{)*hwvSb76bj6;Re;H$(GS@5_L9+eKV>*P849lD9TGYz` zk;hHxb7+>Aef4D_=Umq{*)Aqj|`^vU?#WQa|A8hm4`HVTt8N2 zWYm|<{O6b@*29&dJvBz7Mz&-=NB6#WHVOV^A+_b8YmQ9dr(Nl}FkDx{Bxa#k64=xU z7yK-w_e&YTs-|qiPA-boM2}7 zjLa>CkMwoG)hbx@KN^)^h25xeH{d}uh}E#OwRyNqkTN&F>x@-9G^rV&f5m1!3vqtgGq87OeOc^Cy2NQ=r#?0=VK?1>N3zs|=`6p)PH>C$E;5M`Gf=@4VOdb*S- zHU6^{ht@~OmaNDuUz#W+C`y~f#@TviAgsVl*LpcPqWW;MUeichRdoVtrL2gcZ!~RB zn@*&Z8Vv)1bO>cUFGdu*j$%y^{WcukhcLY|$)5c<>KiHW0<$hl-#1!P)NWkbJc+&U zh#R?E@cIezEPZ&Fy~#2L>#&e@0eLS&_M5r*xj9R!y=VvIM1hZ|5banvxhY%+D_3VU zZn_~Jo&Btl&+W?woO?c{k9rY!3J?(|j`5W@&{~2a_Q9e!sC0{8$0+gc)GHnj_};7$ zKYcMZ{6tegJN#I4^^x0a=1{W6Y?mt1UOE@9`9K{%WUlg)>Oujoae`R>yZhzqNUYfj z$>|0{TUWGqzJx8B)T>y2A73}2I#-);+$l4@gm<|$L#7dbmYE<(+FN}n`e8uLmpWZL zSptYdr+qRIAAehM+h(=p5%3JmrNiL)tQ^ygEq}FdXLBjkxh<Qi9FDF-S~Tf?{w_8h)bv9h#$jO^9i``JHs_f zFzqRAGlR&S%fx1lc*aswiA{MtznciQ_|Mr|o)ucm6g2bPkwkb%;~Y$rBZ26eJ+BYw zOiKKx+mz6T(mFAW0aZLRnk{0F_iF#>)i88?nin8UvczV?)=LnUr zjp^|tl4mu-So$dDAbxilE7p|5SJ9WWz$3x3R!Q%g zU3U;qf5y#C+HIMlgZ4n`qI>k0&se8jk4Hla|9P}xZPZ)!60jA7`){}V{2Um{H1%J9@Z6SiYOYi zWltJX_6CbW$LT8%4-fEz2t{jo1KPLyTdR@z8NC|^bMuVBZx~W=pQt**!k0{y>(JTK zvewyt5*c;gtRid`Z%Y8&T0GvkQanLAh&>Z+bub*AWT}8H}HJjQ5y!`wZt(s7rDfUH$F2 zAh%`iRk)#$jXXHaIOn*qa)ErmrOKSF|c(*^x<=6Px z*>-+l6*u^1p zz7wz!vWpV$CPZP{hI`B@__HUTgD;d$KNAvj(Q}rd5cOw*B0PA!-pV-Oet?~Gu1wlh zxCu4Cn-z11WrlLHFj9W7d4ogdwEy~)ET}+uv5`6=cp>Ttv>DwElVOi|FS_WkNz`D- zdUFXgWit9USy|Ll=m)`R16^+!^MSlLm;Pyotb}beF6Pe&=Z6yZY%LvL7oGSoY#>En z{26qc-%)C%71q}oSAX(>>(lHlvINmn`HfHXr0P-zXQy>qI@6cx4~*n>78blsk%y%5W}p|>53#|$?>RYmTje_dU*15z9-VnWA8tS4{D8XgSiUW1?33rDRB!s)*M|m1r65`D2Ma z17fgeOzDzQll&c@Z@$?g0;L`{_>Cdur}4JMPpCP603l}1kahj)z}Yi|z^O~>q)4qB z8+0AFPzVa)OCT2c(hA;Ljo?K;&9d)SE&W3DbmqHGcc+1Oy&N?1PR(x>0ZgtKxIw(N zSQ$#|k)MY_W5NA-n%wo9 zk4+1DeQ4mUcO9K-1G{c;uUZ31)KCdamDJA+PmMhn!!{b`0-#l1mQ`Yh=DxJ1f6buh zQATvRZSA3iT8(db3EwQ)ni0^ua?Wg3w z=<9RpCruf~P}K;uF$cni4t)Ok?e3xy?lKQM^YXql3{wM#)Pp=xI~aw`hdbIcs&#oF zL{W5oJtJNktU6!@7ddgsms5tk@k_)PqiB04M zs+c%G1`euh+AahNQ?AA^82F$HW3KXtycY3qP1=`Xf)PM8`iFffgLNoM{hK0MKia;7 z;^c&=u-8_=+XO#pO9$kSf~A&{H3zf@(+6dZ9entKV9dE)S`-l_9-5wJu?2$>()m5g zGJrkiK!g;yZ#AOEGF4n)trz?AZukR~k_rx(@ZfA|2wMI)C%j&C$XTwE0W&&m(R_e4 z+0gV>U*dh(7%oHeh_}(&t1S*=RA9+EkvYLoQL&&}& zaPDD9k3k=!@r_QpXJBhYq!X-e8r%g-3RKh1zETgk?9($tjKd_ula&Vx@RMD zM@ykpT)x@EaZY%kXHCPFD&MT zQ#c(T>b%$2^!8eNyMJyrSJ3wUIVF%zkX_I#pp;}C%4Db2&Tb+R;dsIAd~4_ydR8CP z$J7c>eYhlCOxGZ}63SQ`x^D>|f}|1|GOnX)tP!#fu{N#fqQCgPrV3H^wUL$sI;HG- z`2$AcJ*@8!44+zDd1h;!u{!WrXa3uHrs1kmc%c#cx(RU;AFLy-flnTkil{P0wc4v+ zV_A7|;TaeVJ={Fy6QqNoOFVGMu4t|gWGA%42(K!FpX}h~0%U<>x0CMru8!snz|O}o zwx$T)?6@nVWd_a0ZB@`Ww9X!KIcm$efr~YSe5ds^iFBUBT$#_GpX)`@U+XZW?<#H=5k1a_LGrK!9|a@ zctg^AENF93j9m0gJVnVW@wt=heiN#LNXnSRO%%l4)sC&GB3`4mG~KH zA)IqR6t_M1gF22zR!|qkr%&^X66oNLFTEEC(Y`i=7Q+1$4uPAwKVW^Yv$(qaw%yHt z9O5PNVIb)2A}JW;O$Xev_3hQNs5=*fAF;Gh#@6N9mWt*&Vq_dp;O5-3w2iGdNikff zz;ZmO)hB08P^>jIu(mZvI0A?C%6+Y*E+6#@@V@t-t+)1p(cVcv5 zZ%v>10tIc>{-S92`7=N7Sd$~@PFM&@&l#txzxJq)mCTAGHQF|O9NGz=x4wN?j)wP7p{s}q(yCe@D>%Fw zhVUGWYcei&K;DmUE~<2YOj6X+ zLR{>TVBf}EQ+WiT!$wBBL|bTGgNd4g0-s*tuu<$y_RNgW(BXRjD*B|O4M)d^qlNPm zdmG+2Z+F-GZ1?NqVcE$P2`(vAL{^FN&p}Hn2X2!nu$>q;+MMYvxt=wzBq*#>udYpY zdQ+srdH7FotD-jOO@~qRIp!!O-`kU)jbgdc-AigqpFCM9WkZ?1F&GJ0m)zTkxVqR& zxPEr^@St@44B80%v@83GCF3X;hr~WU`xPdnw;X-P$Rcu! zxNf}y8nB`!rewxoU~6_uWs?Nh+LR?tA8J#T_n(tVO%`PWOKmq#=U2{C1Nz5#Zdpu0 zs^(ZiLaLcmaLox3Zm0!qFgMB^%2YHpb}k+dJrD|oEM$(>$Lg;n!!`cSjUvg7R;%Dj zDHdB82VrBd!HUOa0oII`t?wzfCR9s$q6q zoK&Y=t3+i3F_0k#RQNfbV-W>1<1SRn!Y(xW)7okbLaan6U^Kz()R^(pd#0$b(p)Tw zV~kCE)}Txk3U6w!Q;a?N0Cv7fQPi6zXe4tywkPvqN~8uOugol2$nm1)A$X8=COUfP zoTy>lQngeCwOYPn-alG6I7Tfd2Uz02ClBp#S?q0y-1=?Uj+Msgt>> zlcABbi>0lttDQOEBmg)NXnUWdm8%CV5HQ$pfAp_!f7dR?FYU_yX}iu&#{Z>lG{FBs z0WqnA>FSU-^nZf{0-69^F2TRs1_u1Lz(4CR+m_sMzI^fWUGNVrzY|4ijjwf_># zZzQXK3<^hkIlBVX6idKF`E`2M8T=QJr7@rqIJ=qu)0kGq#TJ49rA3wCKeG)4AbbAj zNv&+^VDI!#^5FNbtE!1;N(bEY^9Ef1ui3V3^;ejGiK_pDZ2O%8nV)#0~3`(H4)DoT}3fCNwn z)aYLeXG_XoVE~E+F3v7ahX3|5{<{kOPYP#f+FyYrU0fXg@eF-Lb$KEKyafyZI*sI) z8M^lO03ip<|E0%}g+5yRrX`^Q@Oq~Fg`)#_hyL?i{#PK9fE&#=rcVD;uKmE?|9%Jv zC>c=ue+^2p=xvTZz|Fsq*RsAhT8DOSk>h!;$miCL&v;buX zki`J6>_1l0`u4v$!0?~i=n_0(SL8MGf?OZHf{%t(> zcUk@WEIsbHL}~ye-6(+dYcOnsf5lO^|3_<%-yt8>NtHSQ)w2@+KZ6k({VRy5p^KrB zq4WQ=CivCG{Es7}HTzebKS)#kU61{fIzY4aSCD@j4gMYA_ul%|X7GV-0#K literal 0 HcmV?d00001 diff --git a/v5minimal.php b/v5minimal.php new file mode 100644 index 0000000..c92cba5 --- /dev/null +++ b/v5minimal.php @@ -0,0 +1,5540 @@ +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; im Echtbetrieb hier die feste Domain eintragen + + 'show_official_banner' => true, // true/false: gelbes Band mit dem Hinweis, dass die Seite nicht von einer Behörde stammt + + 'testmode_end' => 'gui', // gui = Umschalten unter /setup, edit = Umschalten sobald die Werte hier stimmen (Fehler stehen im Protokoll) + + '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(); + + testmode_edit_tick(); +} + +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 testmode_config(): array +{ + return [ + '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'] ?? ''), + ]; +} + +function testmode_edit_tick(): void +{ + if ((string) SW::$cfg['testmode_end'] !== 'edit' || !test_mode()) { + return; + } + $last = (int) (SW::$db->val("SELECT v FROM schema_info WHERE k = 'edit_check'") ?? 0); + $now = time(); + if ($now - $last < 300) { + return; + } + SW::$db->run( + "INSERT INTO schema_info (k, v) VALUES ('edit_check', ?) ON CONFLICT(k) DO UPDATE SET v = ?", + [(string) $now, (string) $now] + ); + + $values = testmode_config(); + [$errors] = setup_validate($values); + if ($errors === [] && $values['eid_mode'] === 'eid' && !setup_probe($values)) { + $errors[] = 'flash.setup_failed'; + } + if ($errors === [] && $values['eid_mode'] === 'demo') { + $count = setup_probe_list($values['authorized_keys_url']); + if ($count === null || $count === 0) { + $errors[] = 'setup.err_list_empty'; + } + } + if ($errors !== []) { + log_line('SETUP', 'testmode_edit_invalid', ['errors' => $errors]); + return; + } + test_mode_end(); + log_line('SETUP', 'testmode_ended_by_edit', []); +} + +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; + if ($full === '') { + $full = '/'; + } + $carry = lang_stored() === '' ? lang_wanted() : ''; + if ($carry !== '' && strpos($path, 'lang=') === false + && preg_match('#^/(a/|favicon|apple-touch-icon|icon-192|server\.pub|robots\.txt)#', $path) !== 1) { + $full .= (strpos($full, '?') === false ? '?' : '&') . 'lang=' . rawurlencode($carry); + } + return $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}|auth|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 lang_valid(string $code): bool +{ + return in_array($code, (array) SW::$cfg['langs'], true); +} + +function lang_stored(): string +{ + $value = (string) ($_COOKIE['sw_lang'] ?? ''); + return lang_valid($value) ? $value : ''; +} + +function lang_wanted(): string +{ + $value = query_str('lang', 5); + return lang_valid($value) ? $value : ''; +} + +function lang_store(string $code): void +{ + if (!lang_valid($code)) { + return; + } + setcookie('sw_lang', $code, [ + 'expires' => time() + 31536000, + 'path' => '/', + 'secure' => sw_is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + +function lang_active(): string +{ + $lang = lang_stored(); + if ($lang === '') { + $lang = lang_wanted(); + } + return $lang === '' ? lang_from_browser() : $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.official' => '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.missing' => 'Fehlt noch', + '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' => 'Noch keine freigegebenen Ausweis-Schlüssel.', + 'setup.path' => 'Zugang', + 'setup.path_eid' => 'Eigener eID-Server', + 'setup.path_list' => 'Eigene Trust-Liste', + 'setup.f_server' => 'eID-Server', + 'setup.f_cert' => 'Client-Zertifikat', + 'setup.f_key' => 'Privater Schlüssel', + 'setup.f_client' => 'Ausweis-App', + 'setup.f_sync' => 'Adresse der Trust-Liste', + 'setup.f_nect' => 'Nect Wallet', + '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.home' => 'Startseite', + 'footer.imprint' => 'Impressum', + 'footer.privacy' => 'Datenschutz', + + 'imprint.h' => 'Impressum', + 'imprint.p1' => "Florian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + 'imprint.p2' => 'E-Mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Verantwortlich für den Inhalt:\nFlorian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + + '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 POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven\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.title' => 'Cookies', + 'consent.accept' => 'Akzeptieren', + 'error.consent' => 'Dafür wird das Sitzungs-Cookie benötigt. Bitte auf der Anmeldeseite zustimmen.', +]; + +const SW_EN = [ + 'app.tagline' => 'Digital citizen participation', + 'banner.official' => '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.missing' => 'Still missing', + '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' => 'No authorised ID keys yet.', + 'setup.path' => 'Access', + 'setup.path_eid' => 'Own eID server', + 'setup.path_list' => 'Own trust list', + 'setup.f_server' => 'eID server', + 'setup.f_cert' => 'Client certificate', + 'setup.f_key' => 'Private key', + 'setup.f_client' => 'ID app', + 'setup.f_sync' => 'Trust list address', + 'setup.f_nect' => 'Nect Wallet', + '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.home' => 'Home', + 'footer.imprint' => 'Legal notice', + 'footer.privacy' => 'Privacy', + + 'imprint.h' => 'Legal notice', + 'imprint.p1' => "Florian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + 'imprint.p2' => 'E-mail: impressum@buergerabstimmung.org', + 'imprint.p3' => "Responsible for the content:\nFlorian Kutzer\nc/o POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven", + + '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 POSTFLEX PFX-730-065\nEmsdettener Straße 10\n48268 Greven\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.title' => 'Cookies', + 'consent.accept' => 'Accept', + 'error.consent' => 'This needs the session cookie. Please accept on the sign-in page.', +]; + +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-actions { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center; } + +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']); + $b = base_path(); + $a = SW::$base; + + $html = '' + . '' + . '' + . '' . e($title) . ' · ' . e((string) $cfg['app_name']) . '' + . '' + . icon_links() + . '' + . ''; + if (!empty($cfg['show_official_banner'])) { + $html .= '
' . e(t('banner.official')) . '
'; + } + $html .= '' + . '' + ; + + $flashes = take_flashes(); + if ($flashes !== []) { + $html .= '
'; + foreach ($flashes as $f) { + $html .= '
' + . e(t((string) $f['key'], (array) $f['repl'])) . '
'; + } + $html .= '
'; + } + $html .= '
' . $content . '
' + . ''; + 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 = '
' + . '' + . '' + . '' + . '' + . '
' + . (lang_stored() === '' && lang_wanted() !== '' ? '' : '') + . '
'; + $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_cookie(): 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')) . '

' + ; + $missing = array_values(array_filter(setup_ready(), static function (array $row): bool { + return !$row[1]; + })); + if ($missing !== []) { + $html .= '

' . e(t('setup.missing')) . '

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

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

' + . '' + . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '' + . '
' + . '
' + . ($stable > 0 ? '' : '

' . e(t('setup.check_keys')) . '

') + . '' + . '
' + . '
' + . '
'; + 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)); + + if (!consent_given()) { + render(t('consent.title'), '
' . icon_cookie() + . '

' . e(t('consent.title')) . '

' + . '

' . e(t('consent.text')) . '

' + . '
'); + } + + $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 = trim((string) SW::$cfg['domain']); + if ($host === '') { + $host = (string) ($_SERVER['HTTP_HOST'] ?? ''); + } + if (preg_match('/^[A-Za-z0-9]([A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?$/', $host) !== 1) { + $host = 'localhost'; + } + 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); + $to = consent_return(); + $banner = empty(SW::$cfg['show_official_banner']) + ? '' + : '
' . e(SW_DE['banner.official']) . ' / ' . e(SW_EN['banner.official']) . '
'; + echo '' + . '' + . '' + . '' . e((string) SW::$cfg['app_name']) . '' + . '' + . icon_links() + . '' . $banner + . '
' + . '

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

' + . '
'; + 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_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 = lang_active(); + if (!lang_valid($lang)) { + $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(); + lang_store(lang_active()); + redirect(consent_return()); + } + if ($path === '/lang' && $reading) { + $wish = query_str('set', 5); + if (consent_given()) { + lang_store($wish); + redirect(consent_return()); + } + redirect(consent_return() . (lang_valid($wish) ? '?lang=' . rawurlencode($wish) : '')); + } + + $public = $path === '/' || preg_match('#^/topic/\d{1,10}$#', $path) === 1 + || in_array($path, ['/start', '/auth', '/imprint', '/privacy', '/api/topics'], true); + if (!consent_given() && !($reading && $public)) { + v_error(403, 'error.consent'); + } + + $langChosen = lang_stored() !== '' || lang_wanted() !== ''; + if (!$langChosen && $reading + && !in_array($path, ['/start', '/imprint', '/privacy'], true) + && strpos($path, '/eid/') !== 0 + && strpos($path, '/api/') !== 0) { + redirect('/start?to=' . rawurlencode($path)); + } + 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 === '/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/v5minimalsplit.zip b/v5minimalsplit.zip new file mode 100644 index 0000000000000000000000000000000000000000..a987ca619798ffe1e6683a9ab603a7e0b377a127 GIT binary patch literal 84914 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 z