furtka/ops/poll/pollsvc.py

173 lines
5.2 KiB
Python
Raw Normal View History

#!/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 (as resolved by
nginx from the trusted edge proxy) + 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:
# nginx resolves the trusted proxy chain (real_ip module, see
# ops/nginx/furtka.org.conf) and hands us the result as X-Real-IP.
# We never parse X-Forwarded-For here: its leading entries are
# whatever the client chose to send.
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()