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
170 lines
5 KiB
Python
170 lines
5 KiB
Python
#!/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()
|