#!/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()