diff --git a/ops/nginx/furtka.org.conf b/ops/nginx/furtka.org.conf index ceb94ea..05fe37a 100644 --- a/ops/nginx/furtka.org.conf +++ b/ops/nginx/furtka.org.conf @@ -29,6 +29,16 @@ server { try_files $uri $uri/ $uri.html =404; } + # Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited. + location /api/poll/ { + limit_req zone=poll burst=10 nodelay; + proxy_pass http://127.0.0.1:8090/; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header User-Agent $http_user_agent; + proxy_read_timeout 5s; + } + location = /favicon.svg { access_log off; log_not_found off; diff --git a/ops/nginx/poll-ratelimit.conf b/ops/nginx/poll-ratelimit.conf new file mode 100644 index 0000000..edc517a --- /dev/null +++ b/ops/nginx/poll-ratelimit.conf @@ -0,0 +1,2 @@ +# Rate limit for the poll API (http context; included from conf.d). +limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m; diff --git a/ops/nginx/setup-vm.sh b/ops/nginx/setup-vm.sh index 7c0e19a..df5e7d1 100755 --- a/ops/nginx/setup-vm.sh +++ b/ops/nginx/setup-vm.sh @@ -16,6 +16,7 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT" install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT" cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF" +cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf ln -sfn "$SITE_CONF" "$SITE_LINK" # Drop the Ubuntu default site so it doesn't shadow us on :80. diff --git a/ops/poll/furtka-poll.service b/ops/poll/furtka-poll.service new file mode 100644 index 0000000..c4cb53c --- /dev/null +++ b/ops/poll/furtka-poll.service @@ -0,0 +1,17 @@ +[Unit] +Description=furtka.org poll counter +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py +DynamicUser=yes +StateDirectory=furtka-poll +Restart=on-failure +RestartSec=3 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/ops/poll/pollsvc.py b/ops/poll/pollsvc.py new file mode 100644 index 0000000..5db53dd --- /dev/null +++ b/ops/poll/pollsvc.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Tiny cookie-free poll counter for furtka.org. + +Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies +beyond the standard library. + + GET / -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8} + POST / -> body {"choice": "m"}; answers like GET plus "yours" + +A device is identified by a salted SHA-256 of client IP + User-Agent, so +nobody needs a cookie and the raw address is never stored. Voting again +from the same device changes the vote instead of adding one. +""" + +import hashlib +import json +import logging +import os +import sqlite3 +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +POLLS = { + "logo": ("m", "r"), +} +STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll") +DB_PATH = os.path.join(STATE_DIR, "votes.db") +SALT_PATH = os.path.join(STATE_DIR, "salt") +LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1") +PORT = int(os.environ.get("POLL_PORT", "8090")) +MAX_BODY = 512 + +log = logging.getLogger("pollsvc") +_lock = threading.Lock() + + +def _salt() -> bytes: + try: + with open(SALT_PATH, "rb") as f: + s = f.read() + if len(s) >= 16: + return s + except FileNotFoundError: + pass + s = os.urandom(32) + with open(SALT_PATH, "wb") as f: + f.write(s) + os.chmod(SALT_PATH, 0o600) + return s + + +def _db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.execute( + "CREATE TABLE IF NOT EXISTS votes (" + " poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL," + " ts INTEGER NOT NULL DEFAULT (unixepoch())," + " PRIMARY KEY (poll, device))" + ) + conn.commit() + return conn + + +SALT = None +CONN = None + + +def counts(poll: str) -> dict: + with _lock: + rows = CONN.execute( + "SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,) + ).fetchall() + c = {k: 0 for k in POLLS[poll]} + for choice, n in rows: + if choice in c: + c[choice] = n + return {"poll": poll, "counts": c, "total": sum(c.values())} + + +class Handler(BaseHTTPRequestHandler): + server_version = "furtka-poll/1" + + def log_message(self, fmt, *args): # quiet; nginx has the access log + log.debug(fmt, *args) + + def _client_ip(self) -> str: + xff = self.headers.get("X-Forwarded-For") + if xff: + return xff.split(",")[0].strip() + return self.headers.get("X-Real-IP") or self.client_address[0] + + def _device(self) -> str: + ua = self.headers.get("User-Agent", "") + h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode()) + return h.hexdigest()[:32] + + def _json(self, status: int, payload: dict): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _poll(self): + name = self.path.strip("/").split("?")[0] + if name not in POLLS: + self._json(404, {"error": "no such poll"}) + return None + return name + + def do_GET(self): + poll = self._poll() + if not poll: + return + payload = counts(poll) + with _lock: + row = CONN.execute( + "SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device()) + ).fetchone() + payload["yours"] = row[0] if row else None + self._json(200, payload) + + def do_POST(self): + poll = self._poll() + if not poll: + return + try: + n = int(self.headers.get("Content-Length", "0")) + if n > MAX_BODY: + raise ValueError("body too large") + data = json.loads(self.rfile.read(n) or b"{}") + choice = str(data.get("choice", "")) + except (ValueError, json.JSONDecodeError): + self._json(400, {"error": "bad request"}) + return + if choice not in POLLS[poll]: + self._json(400, {"error": "unknown choice"}) + return + with _lock: + CONN.execute( + "INSERT INTO votes (poll, device, choice) VALUES (?,?,?)" + " ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()", + (poll, self._device(), choice), + ) + CONN.commit() + payload = counts(poll) + payload["yours"] = choice + self._json(200, payload) + + +def main(): + global SALT, CONN + logging.basicConfig( + level=os.environ.get("POLL_LOGLEVEL", "INFO"), + stream=sys.stderr, + format="%(levelname)s %(message)s", + ) + os.makedirs(STATE_DIR, exist_ok=True) + SALT = _salt() + CONN = _db() + srv = ThreadingHTTPServer((LISTEN, PORT), Handler) + log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/ops/poll/setup-poll.sh b/ops/poll/setup-poll.sh new file mode 100755 index 0000000..f1ecd14 --- /dev/null +++ b/ops/poll/setup-poll.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Install / update the poll counter on forge-runner-01. Idempotent. +# +# Usage (on the VM, with sudo): +# sudo ops/poll/setup-poll.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" + +install -d -m 0755 /opt/furtka-poll +install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py +install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service + +systemctl daemon-reload +systemctl enable --now furtka-poll +systemctl restart furtka-poll +sleep 1 +systemctl --no-pager --lines=3 status furtka-poll +curl -fsS http://127.0.0.1:8090/logo && echo +echo "OK: furtka-poll running on 127.0.0.1:8090" diff --git a/website/README.md b/website/README.md index b9c6b89..0634614 100644 --- a/website/README.md +++ b/website/README.md @@ -42,6 +42,15 @@ This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs `hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path, just with an SSH hop. +### Poll counter + +`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`, +SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or +update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location +and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told +apart by a salted hash of IP + user agent — no cookie — and a repeat vote +from the same device changes the choice instead of adding one. + ### First-time VM setup Only needed once, when provisioning a fresh forge-runner VM: @@ -58,11 +67,14 @@ hugo.toml Hugo config (multilingual: en default, de) content/ Markdown pages _index.md Home (EN) _index.de.md Home (DE) + logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/ layouts/ Custom inline theme — no external theme or framework _default/ baseof, single, list - partials/ head, header, footer, gate SVG, lang switcher + partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll) + poll/single.html Logo poll page; strings come from the content front matter index.html Home-only layout with editorial hero assets/css/main.css Stylesheet (fingerprinted + minified on build) +assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage static/favicon.svg Gate mark in crimson deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes) deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI diff --git a/website/assets/css/main.css b/website/assets/css/main.css index 3fea81a..aeb5bb2 100644 --- a/website/assets/css/main.css +++ b/website/assets/css/main.css @@ -439,3 +439,92 @@ main.container { will-change: auto; } } + +/* ── Logo poll ───────────────────────────────────────────────── */ + +.poll-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + gap: 1.5rem; + margin-top: 2.5rem; +} +.poll-card { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 1.4rem; + border: 1px solid var(--card-border); + border-radius: 1rem; + background: var(--card-bg); + transition: border-color 160ms, box-shadow 160ms; +} +.poll-card.is-yours { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} +.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; } +.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; } +.poll-art { + display: grid; + place-items: center; + padding: 2.2rem 1rem; + border-radius: 0.75rem; +} +.poll-art--m { background: var(--bg-subtle); color: var(--accent); } +.poll-art--r { background: #151515; color: #af6428; } +.pm { display: block; } +.pm--big { height: 9rem; width: auto; } +.pm--head { height: 1.1em; width: auto; } +.poll-ctx { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} +.poll-ctx-head, .poll-ctx-tab { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.7rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + font-size: 0.82rem; + color: var(--fg-muted); +} +.poll-ctx-head { color: var(--accent); } +.poll-ctx-head--r { color: #af6428; } +.poll-ctx-name { + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + font-size: 0.7rem; + color: var(--fg); +} +.poll-ctx-tab img { image-rendering: pixelated; } +.poll-btn { + margin-top: auto; + padding: 0.85rem 1.2rem; + border: 1px solid var(--accent); + border-radius: 0.6rem; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background 140ms, transform 140ms; +} +.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); } +.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } +.poll-btn:disabled { cursor: default; opacity: 0.85; } +.is-yours .poll-btn { background: transparent; color: var(--accent); } +.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; } +.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; } +.poll--voted .poll-bar { display: block; } +.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; } +.poll-status { margin: 1.5rem 0 0; min-height: 1.5em; color: var(--fg-muted); } +.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); } +.poll-note a { color: var(--accent); } +@media (prefers-reduced-motion: reduce) { + .poll-bar i, .poll-btn { transition: none; } +} diff --git a/website/assets/js/poll.js b/website/assets/js/poll.js new file mode 100644 index 0000000..70cb18b --- /dev/null +++ b/website/assets/js/poll.js @@ -0,0 +1,57 @@ +// furtka.org logo poll — no cookies, no storage. The server tells us +// what this device already chose (salted hash of IP + user agent). +(function () { + var root = document.querySelector('.poll'); + if (!root || !window.fetch) return; + var api = root.dataset.api; + var status = root.querySelector('.poll-status'); + var t = status.dataset; + var buttons = root.querySelectorAll('[data-vote]'); + var busy = false; + + function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; } + + function render(d) { + var total = d.total || 0; + root.classList.toggle('poll--voted', !!d.yours); + Object.keys(d.counts).forEach(function (k) { + var card = root.querySelector('[data-choice="' + k + '"]'); + if (!card) return; + var n = d.counts[k], p = pct(n, total); + card.classList.toggle('is-yours', d.yours === k); + card.querySelector('.poll-bar i').style.width = p + '%'; + card.querySelector('[data-count]').textContent = d.yours ? p + ' % · ' + n : ''; + var b = card.querySelector('[data-vote]'); + b.disabled = d.yours === k; + b.textContent = d.yours === k ? t.tVoted : (d.yours ? t.tChange : b.dataset.label); + }); + status.textContent = d.yours ? t.tTotal.replace('{n}', total) : ''; + } + + function load() { + fetch(api, { headers: { Accept: 'application/json' } }) + .then(function (r) { if (!r.ok) throw r; return r.json(); }) + .then(render) + .catch(function () { status.textContent = t.tError; }); + } + + buttons.forEach(function (b) { + b.dataset.label = b.textContent; + b.addEventListener('click', function () { + if (busy) return; + busy = true; b.disabled = true; + fetch(api, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ choice: b.dataset.vote }) + }) + .then(function (r) { if (!r.ok) throw r; return r.json(); }) + .then(render) + .catch(function () { status.textContent = t.tError; b.disabled = false; }) + .then(function () { busy = false; }); + }); + }); + + status.textContent = ''; + load(); +})(); diff --git a/website/content/datenschutz.de.md b/website/content/datenschutz.de.md index 7de6dfd..ac3bc44 100644 --- a/website/content/datenschutz.de.md +++ b/website/content/datenschutz.de.md @@ -11,7 +11,8 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder Skripte von Drittanbietern**, bindet **keine Analyse- oder Tracking-Dienste** ein und enthält **keine externen Einbettungen** (YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind -ausschließlich kurzfristige Server-Zugriffsprotokolle. +ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die +[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten). ### Verantwortlicher @@ -43,6 +44,22 @@ an Betrieb und Sicherheit). Infrastruktur; es gibt keinen externen Auftragsverarbeiter. **Drittlandübermittlung:** keine. +### Logo-Abstimmung + +Auf der Seite [/de/logo/](/de/logo/) können Sie zwischen zwei +Logo-Vorschlägen abstimmen. Damit jedes Gerät ohne Cookie einmal gezählt +wird, speichert der Abstimmungsserver einen **gesalzenen SHA-256-Hash aus +Ihrer IP-Adresse und Browser-Kennung** zusammen mit Ihrer Wahl und dem +Zeitpunkt der Stimmabgabe. Der Hash lässt sich nicht in die Adresse +zurückrechnen; das Salt verlässt den Server nicht. + +**Zweck:** Zählung je Gerät einmal. +**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse +an einer ehrlichen Auszählung; die Teilnahme ist freiwillig. +**Speicherdauer:** bis zum Ende der Abstimmung, danach wird die Datenbank +gelöscht. +**Empfänger:** keine. + ### Cookies und Tracking Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder @@ -75,5 +92,5 @@ Website: ### Stand -Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026 +Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026 aktualisiert. diff --git a/website/content/datenschutz.md b/website/content/datenschutz.md index 4c88952..4ae3beb 100644 --- a/website/content/datenschutz.md +++ b/website/content/datenschutz.md @@ -11,7 +11,8 @@ sitemap: This website sets **no cookies**, loads **no third-party fonts or scripts**, embeds **no analytics or tracking services**, and contains **no external embeds** (YouTube, Maps, social buttons, …). The only -technical data collected is short-lived server access logs. +technical data collected is short-lived server access logs — plus, while +the [logo poll](/logo/) is running, one salted hash per vote (see below). ### Controller @@ -43,6 +44,20 @@ and security. external processor is involved. **Transfers outside the EU/EEA:** none. +### Logo poll + +The page [/logo/](/logo/) lets you vote between two logo proposals. So +that each device is counted once without a cookie, the poll server stores +a **salted SHA-256 hash of your IP address and browser identification** +together with your choice and the time of the vote. The hash cannot be +turned back into the address; the salt never leaves the server. + +**Purpose:** counting votes once per device. +**Legal basis:** Art. 6(1)(f) GDPR — legitimate interest in an honest +tally; voting is voluntary. +**Retention:** until the poll is closed, then the database is deleted. +**Recipients:** none. + ### Cookies and tracking None. No cookies are set, no localStorage or sessionStorage is used, and @@ -75,6 +90,6 @@ Website: ### Last updated -This statement was last updated on 2026-04-18. +This statement was last updated on 2026-08-26. The German version of this privacy statement is the legally binding one. diff --git a/website/content/logo.de.md b/website/content/logo.de.md new file mode 100644 index 0000000..9e57132 --- /dev/null +++ b/website/content/logo.de.md @@ -0,0 +1,30 @@ +--- +title: "Welches Logo?" +description: "Zwei Vorschläge für das Furtka-Zeichen. Wähl das, das dir besser gefällt — ein Klick genügt." +translationKey: "logo" +layout: single +type: poll +poll: logo +sitemap: + priority: 0.5 +m: + title: "M — das f im Tor" + text: "Ein kleines f, aus einem Rundbogentor ausgestanzt. Der Stamm ist der Torpfosten, die zwei Balken sind die Querriegel." +r: + title: "R — Fenster, Rad und F" + text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F." +labels: + vote: "Dieses hier" + voted: "Deine Wahl" + change: "Zu diesem wechseln" + total: "Bisher {n} Stimmen. Danke!" + error: "Die Abstimmung ist gerade nicht erreichbar — bitte gleich noch einmal versuchen." + nojs: "Zum Abstimmen wird JavaScript benötigt." + note: "Keine Cookies, kein Konto. Damit jedes Gerät einmal zählt, speichert der Server einen gesalzenen Hash aus IP-Adresse und Browser — nicht die Adresse selbst. Details in der Datenschutzerklärung." +--- + +*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch +die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen +damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs +Tor auf ein Rad. Unter jedem Zeichen siehst du, wie es in der Kopfzeile +der Seite sitzt und als echtes 16-Pixel-Symbol im Browser-Tab. diff --git a/website/content/logo.md b/website/content/logo.md new file mode 100644 index 0000000..75c954f --- /dev/null +++ b/website/content/logo.md @@ -0,0 +1,31 @@ +--- +title: "Which logo?" +description: "Two proposals for the Furtka mark. Pick the one you like better — it takes one click." +translationKey: "logo" +url: /logo/ +layout: single +type: poll +poll: logo +sitemap: + priority: 0.5 +m: + title: "M — the f in the gate" + text: "A lowercase f cut out of an arched gate. The stem is the gate post, the two rails are the crossbars." +r: + title: "R — window, wheel and F" + text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F." +labels: + vote: "This one" + voted: "Your pick" + change: "Switch to this one" + total: "{n} votes so far. Thank you!" + error: "Couldn't reach the poll right now — please try again in a moment." + nojs: "Voting needs JavaScript." + note: "No cookies, no account. To count each device once, the server keeps a salted hash of your IP address and browser — not the address itself. Details in the privacy notice." +--- + +*Furtka* is Polish for a small garden gate — the little door in the fence +that lets you in without opening the whole thing. Both marks play with that: +one draws the letter into the gate, the other looks through the gate at a +wheel. Below each mark you see how it sits in the site header and as a +real 16-pixel browser-tab icon. diff --git a/website/layouts/partials/mark-m.html b/website/layouts/partials/mark-m.html new file mode 100644 index 0000000..fb1bbaf --- /dev/null +++ b/website/layouts/partials/mark-m.html @@ -0,0 +1,2 @@ +{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}} + diff --git a/website/layouts/partials/mark-r.html b/website/layouts/partials/mark-r.html new file mode 100644 index 0000000..07a8173 --- /dev/null +++ b/website/layouts/partials/mark-r.html @@ -0,0 +1,2 @@ +{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}} + diff --git a/website/layouts/poll/single.html b/website/layouts/poll/single.html new file mode 100644 index 0000000..9b4bcbc --- /dev/null +++ b/website/layouts/poll/single.html @@ -0,0 +1,48 @@ +{{ define "main" }} +{{- $p := .Params -}} +
+ +
{{ .Content }}
+ +
+
+
{{ partial "mark-m.html" (dict "class" "pm--big" "id" "m") }}
+
+ {{ partial "mark-m.html" (dict "class" "pm--head" "id" "mh") }}Furtka + Furtka +
+

{{ $p.m.title }}

+

{{ $p.m.text }}

+ + +

+
+ +
+
{{ partial "mark-r.html" (dict "class" "pm--big" "id" "r") }}
+
+ {{ partial "mark-r.html" (dict "class" "pm--head" "id" "rh") }}Furtka + Furtka +
+

{{ $p.r.title }}

+

{{ $p.r.text }}

+ + +

+
+
+ +

{{ $p.labels.nojs }}

+

{{ $p.labels.note | safeHTML }}

+
+{{ $js := resources.Get "js/poll.js" | minify | fingerprint }} + +{{ end }}