feat(website): logo poll at /logo/ with cookie-free vote counter
Some checks failed
Some checks failed
Two marks (M and R) side by side, EN + DE, with the site-header and real 16 px favicon context for each. Votes go to ops/poll/pollsvc.py, a stdlib Python service behind nginx at /api/poll/ (SQLite, systemd DynamicUser, rate-limited). Devices are counted once via a salted hash of IP + user agent; the privacy pages document it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
This commit is contained in:
parent
8b07b57242
commit
2fbafd5c77
16 changed files with 527 additions and 5 deletions
|
|
@ -29,6 +29,16 @@ server {
|
||||||
try_files $uri $uri/ $uri.html =404;
|
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 {
|
location = /favicon.svg {
|
||||||
access_log off;
|
access_log off;
|
||||||
log_not_found off;
|
log_not_found off;
|
||||||
|
|
|
||||||
2
ops/nginx/poll-ratelimit.conf
Normal file
2
ops/nginx/poll-ratelimit.conf
Normal file
|
|
@ -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;
|
||||||
|
|
@ -16,6 +16,7 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
|
||||||
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
|
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
|
||||||
|
|
||||||
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
|
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"
|
ln -sfn "$SITE_CONF" "$SITE_LINK"
|
||||||
|
|
||||||
# Drop the Ubuntu default site so it doesn't shadow us on :80.
|
# Drop the Ubuntu default site so it doesn't shadow us on :80.
|
||||||
|
|
|
||||||
17
ops/poll/furtka-poll.service
Normal file
17
ops/poll/furtka-poll.service
Normal file
|
|
@ -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
|
||||||
170
ops/poll/pollsvc.py
Normal file
170
ops/poll/pollsvc.py
Normal file
|
|
@ -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> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
|
||||||
|
POST /<poll> -> 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()
|
||||||
19
ops/poll/setup-poll.sh
Executable file
19
ops/poll/setup-poll.sh
Executable file
|
|
@ -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"
|
||||||
|
|
@ -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,
|
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
|
||||||
just with an SSH hop.
|
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
|
### First-time VM setup
|
||||||
|
|
||||||
Only needed once, when provisioning a fresh forge-runner VM:
|
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
|
content/ Markdown pages
|
||||||
_index.md Home (EN)
|
_index.md Home (EN)
|
||||||
_index.de.md Home (DE)
|
_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
|
layouts/ Custom inline theme — no external theme or framework
|
||||||
_default/ baseof, single, list
|
_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
|
index.html Home-only layout with editorial hero
|
||||||
assets/css/main.css Stylesheet (fingerprinted + minified on build)
|
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
|
static/favicon.svg Gate mark in crimson
|
||||||
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
|
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
|
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI
|
||||||
|
|
|
||||||
|
|
@ -439,3 +439,92 @@ main.container {
|
||||||
will-change: auto;
|
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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
57
website/assets/js/poll.js
Normal file
57
website/assets/js/poll.js
Normal file
|
|
@ -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();
|
||||||
|
})();
|
||||||
|
|
@ -11,7 +11,8 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
|
||||||
Skripte von Drittanbietern**, bindet **keine Analyse- oder
|
Skripte von Drittanbietern**, bindet **keine Analyse- oder
|
||||||
Tracking-Dienste** ein und enthält **keine externen Einbettungen**
|
Tracking-Dienste** ein und enthält **keine externen Einbettungen**
|
||||||
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
|
(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
|
### Verantwortlicher
|
||||||
|
|
||||||
|
|
@ -43,6 +44,22 @@ an Betrieb und Sicherheit).
|
||||||
Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
|
Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
|
||||||
**Drittlandübermittlung:** keine.
|
**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
|
### Cookies und Tracking
|
||||||
|
|
||||||
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
|
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
|
||||||
|
|
@ -75,5 +92,5 @@ Website: <https://www.datenschutz.rlp.de>
|
||||||
|
|
||||||
### Stand
|
### 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.
|
aktualisiert.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ sitemap:
|
||||||
This website sets **no cookies**, loads **no third-party fonts or
|
This website sets **no cookies**, loads **no third-party fonts or
|
||||||
scripts**, embeds **no analytics or tracking services**, and contains
|
scripts**, embeds **no analytics or tracking services**, and contains
|
||||||
**no external embeds** (YouTube, Maps, social buttons, …). The only
|
**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
|
### Controller
|
||||||
|
|
||||||
|
|
@ -43,6 +44,20 @@ and security.
|
||||||
external processor is involved.
|
external processor is involved.
|
||||||
**Transfers outside the EU/EEA:** none.
|
**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
|
### Cookies and tracking
|
||||||
|
|
||||||
None. No cookies are set, no localStorage or sessionStorage is used, and
|
None. No cookies are set, no localStorage or sessionStorage is used, and
|
||||||
|
|
@ -75,6 +90,6 @@ Website: <https://www.datenschutz.rlp.de>
|
||||||
|
|
||||||
### Last updated
|
### 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.
|
The German version of this privacy statement is the legally binding one.
|
||||||
|
|
|
||||||
30
website/content/logo.de.md
Normal file
30
website/content/logo.de.md
Normal file
|
|
@ -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 <a href=\"/de/datenschutz/\">Datenschutzerklärung</a>."
|
||||||
|
---
|
||||||
|
|
||||||
|
*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.
|
||||||
31
website/content/logo.md
Normal file
31
website/content/logo.md
Normal file
|
|
@ -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 <a href=\"/privacy/\">privacy notice</a>."
|
||||||
|
---
|
||||||
|
|
||||||
|
*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.
|
||||||
2
website/layouts/partials/mark-m.html
Normal file
2
website/layouts/partials/mark-m.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}}
|
||||||
|
<svg class="pm {{ .class }}" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>
|
||||||
2
website/layouts/partials/mark-r.html
Normal file
2
website/layouts/partials/mark-r.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}}
|
||||||
|
<svg class="pm {{ .class }}" viewBox="0 0 104 114" aria-hidden="true" focusable="false"><defs><clipPath id="pm-rc-{{ .id }}" clip-rule="evenodd"><path clip-rule="evenodd" d="M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"/></clipPath></defs><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><path d="M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"/><path d="M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9" clip-path="url(#pm-rc-{{ .id }})"/><path d="M52 22H79M52 37H69" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g></svg>
|
||||||
48
website/layouts/poll/single.html
Normal file
48
website/layouts/poll/single.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{{ define "main" }}
|
||||||
|
{{- $p := .Params -}}
|
||||||
|
<article class="poll" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
|
||||||
|
</header>
|
||||||
|
<div class="prose">{{ .Content }}</div>
|
||||||
|
|
||||||
|
<div class="poll-grid">
|
||||||
|
<section class="poll-card" data-choice="m" aria-labelledby="poll-m-title">
|
||||||
|
<div class="poll-art poll-art--m">{{ partial "mark-m.html" (dict "class" "pm--big" "id" "m") }}</div>
|
||||||
|
<div class="poll-ctx">
|
||||||
|
<span class="poll-ctx-head">{{ partial "mark-m.html" (dict "class" "pm--head" "id" "mh") }}<span class="poll-ctx-name">Furtka</span></span>
|
||||||
|
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABL0lEQVQ4jcWRP0tCYRTGf+d6+0dDbQ0RuAQaNYdXIi1ssCUQt4YkWvsK9Rn6DNHclEsoDt2oWVDEoCEaWmoKutk9Dd6rLzczwaHf9r7nPM953vMKEa7XVxfsWOcYZQ9hObhuKXI5iXeWdh9ezH4xDxUnuWvhn4PMR40DXkVkf/OmcfXDoJJa2bbELwMTv4hDPF/Z2bpt1noG1Ux8WrypFsjSH+KQx/e5TiJfbn/YAOLNFEENsdwrHGbdRt1UVZ3EqcAJEJ99swvAhdUt+Tmjr6Nfn4WoOIoKOQA7mLho1J6zd+0ngFoq4SCy0RdpGg0UQeKugWIZ/+EbU45AD/pjjQQqMSPBYDJuswSUBuyghzXMYBSGJqilk3mUtfBs7mAkA1UtAgN3EDL2E/7fYGy+AWGMWfwfY1MaAAAAAElFTkSuQmCC" width="16" height="16" alt="">Furtka</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="poll-m-title">{{ $p.m.title }}</h2>
|
||||||
|
<p class="poll-desc">{{ $p.m.text }}</p>
|
||||||
|
<button type="button" class="poll-btn" data-vote="m">{{ $p.labels.vote }}</button>
|
||||||
|
<div class="poll-bar" aria-hidden="true"><i></i></div>
|
||||||
|
<p class="poll-count" data-count="m"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="poll-card" data-choice="r" aria-labelledby="poll-r-title">
|
||||||
|
<div class="poll-art poll-art--r">{{ partial "mark-r.html" (dict "class" "pm--big" "id" "r") }}</div>
|
||||||
|
<div class="poll-ctx">
|
||||||
|
<span class="poll-ctx-head poll-ctx-head--r">{{ partial "mark-r.html" (dict "class" "pm--head" "id" "rh") }}<span class="poll-ctx-name">Furtka</span></span>
|
||||||
|
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAABmJLR0QA/wD/AP+gvaeTAAAB30lEQVQokY2TPUhVYRzGf8855/ox9LHZVkPkrZYsIegDnQPBey1zcKi8ObY0tjTk2hJEXK85FE3pzdUpiyiIIgRJxSiIQEgCl7p6Pedp8GhHuYHP+P8/v+d93hdekdFkKV8MxA2bDqA1s/oDfDAeK1YWpraGAhi/dqTlQNTyTFAQLBvPWLQGqDvBL+WgBu4C2gzPW1o3Bi89WFoLAA7mmsuCXuDu+mp4uFBZGMA8ttmPqdnJV9tPsKYEl9dquYcAejF84qyT5B3mfmFs/vZWpYmhY6VAwSg79Vbw3nAL6AwSJ1eBep1wJOsKk+A7gO0RScV6U7ivUJk/tx5v3AM2LAYC2e2gpf6xuV9ZOAl1FEDSHduTUT0eBugfX/oJfJHJB6Ac8noWNEgkbembPg1julfrtYfbBlEDmiMaqFpqLwuV0qjBOOT39cq3md0+VUv5acFJpDebfcklcq+yJjDWFAH1zTyfN8xFac0m7DObToXadYJBiA7sOB01AURp9I/C6Pyp7dpDx18jX8jAr4qVz13b+5v5TxiCRneOwrhgqKbkRC5I+hr5GsI95cUVJTxK4XJPeXFlz/BepWopPw2cBiayC8uH/n0MLe/i+oCPETALdAJXdqRaGGKhi/85ePYvrfW+Wa23Y78AAAAASUVORK5CYII=" width="15" height="16" alt="">Furtka</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="poll-r-title">{{ $p.r.title }}</h2>
|
||||||
|
<p class="poll-desc">{{ $p.r.text }}</p>
|
||||||
|
<button type="button" class="poll-btn" data-vote="r">{{ $p.labels.vote }}</button>
|
||||||
|
<div class="poll-bar" aria-hidden="true"><i></i></div>
|
||||||
|
<p class="poll-count" data-count="r"></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="poll-status" role="status" aria-live="polite"
|
||||||
|
data-t-voted="{{ $p.labels.voted }}"
|
||||||
|
data-t-total="{{ $p.labels.total }}"
|
||||||
|
data-t-error="{{ $p.labels.error }}"
|
||||||
|
data-t-nojs="{{ $p.labels.nojs }}"
|
||||||
|
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
|
||||||
|
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
|
||||||
|
</article>
|
||||||
|
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
|
||||||
|
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
||||||
|
{{ end }}
|
||||||
Loading…
Add table
Reference in a new issue