import asyncio import hashlib import json import os import secrets import sqlite3 import time import unicodedata from collections import defaultdict, deque from contextlib import contextmanager from itertools import pairwise from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" ROOT = Path(__file__).parent def normalize_code(value): return ( "".join(value.upper().split()) .replace("-", "") .translate(str.maketrans("OIL", "011")) ) def new_code(length): return "".join(secrets.choice(ALPHABET) for _ in range(length)) class Schedule(BaseModel): dates: list[str] = Field(max_length=366) start: str = "09:00" end: str = "17:00" minutes: int = 30 timezone: str @field_validator("minutes") @classmethod def slot_size(cls, value): if value not in (15, 30, 60): raise ValueError("Choose 15, 30 or 60 minutes.") return value @field_validator("timezone") @classmethod def zone(cls, value): try: ZoneInfo(value) except (ZoneInfoNotFoundError, ValueError): raise ValueError("Choose an IANA location timezone.") from None return value class PollSettings(BaseModel): title: str = Field(min_length=1, max_length=120) timezone: str fixed_timezone: bool = False minutes: int = 30 slots: list[int] = Field(min_length=1, max_length=12000) blocked: list[int] = Field(default_factory=list, max_length=12000) settings_revision: int = 0 confirm_loss: bool = False _size = field_validator("minutes")(Schedule.slot_size.__func__) _zone = field_validator("timezone")(Schedule.zone.__func__) @field_validator("title") @classmethod def title_valid(cls, value): value = value.strip() if not value: raise ValueError("Enter an event name.") return value class Votes(BaseModel): name: str = Field(min_length=1, max_length=80) votes: dict[str, str] = Field(default_factory=dict, max_length=12000) revision: int = 0 schedule_revision: int def create_app(data_dir=None): app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) data = Path(data_dir or os.environ.get("TIMEPOLL_DATA_DIR", "/data")) data.mkdir(parents=True, exist_ok=True) database = data / "timepoll.sqlite3" @contextmanager def db(write=False): con = sqlite3.connect(database, timeout=10, isolation_level=None) con.row_factory = sqlite3.Row con.execute("PRAGMA foreign_keys=ON") try: con.execute("BEGIN IMMEDIATE" if write else "BEGIN") yield con con.commit() except BaseException: con.rollback() raise finally: con.close() with sqlite3.connect(database) as con: con.execute("PRAGMA journal_mode=WAL") con.executescript(""" CREATE TABLE IF NOT EXISTS polls ( code TEXT PRIMARY KEY, admin_hash TEXT NOT NULL UNIQUE, title TEXT NOT NULL, timezone TEXT NOT NULL, fixed_timezone INTEGER NOT NULL, minutes INTEGER NOT NULL, slots TEXT NOT NULL, blocked TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, schedule_revision INTEGER NOT NULL DEFAULT 1, settings_revision INTEGER NOT NULL DEFAULT 1); CREATE TABLE IF NOT EXISTS people ( id INTEGER PRIMARY KEY, poll TEXT NOT NULL REFERENCES polls(code) ON DELETE CASCADE, name TEXT NOT NULL, name_key TEXT NOT NULL, votes TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, UNIQUE(poll, name_key)); """) limits = defaultdict(deque) @app.middleware("http") async def guards(request, call_next): if request.method in ("POST", "PUT", "DELETE"): origin = request.headers.get("origin") if origin and origin != str(request.base_url).rstrip("/"): return JSONResponse( {"detail": "Cross-origin writes are not allowed."}, 403 ) # Limit actual bytes, including chunked requests without Content-Length. body = bytearray() async for chunk in request.stream(): body.extend(chunk) if len(body) > 1024 * 1024: return JSONResponse({"detail": "Request too large."}, 413) request._body = bytes(body) if request.url.path.startswith("/api/") and not request.url.path.endswith( "/events" ): now = time.monotonic() # Bound memory and expire idle addresses, including rate-limit keys. for key in list(limits): if not limits[key] or limits[key][-1] < now - 3600: del limits[key] creating = request.method == "POST" and request.url.path == "/api/polls" key = (request.client.host, creating) window, maximum = (3600, 20) if creating else (60, 240) queue = limits[key] while queue and queue[0] <= now - window: queue.popleft() if len(queue) >= maximum: return JSONResponse( {"detail": "Too many requests. Please try again later."}, 429, headers={"Retry-After": str(window)}, ) queue.append(now) response = await call_next(request) response.headers.update( { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY", "Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", } ) if request.url.path.startswith("/api/"): response.headers["Cache-Control"] = "no-store" return response def load(con, code): row = con.execute( "SELECT * FROM polls WHERE code=?", (normalize_code(code),) ).fetchone() if row is None: raise HTTPException(404, "Poll not found. Check the event code.") poll = dict(row) poll["slots"] = json.loads(poll["slots"]) poll["blocked"] = json.loads(poll["blocked"]) poll["fixed_timezone"] = bool(poll["fixed_timezone"]) return poll def people(con, code): return [ { "id": r["id"], "name": r["name"], "votes": json.loads(r["votes"]), "revision": r["revision"], } for r in con.execute( "SELECT * FROM people WHERE poll=? ORDER BY id", (code,) ) ] def authorize(request, poll): token = normalize_code( request.headers.get("Authorization", "").removeprefix("Bearer ") ) if not secrets.compare_digest( hashlib.sha256(token.encode()).hexdigest(), poll["admin_hash"] ): raise HTTPException(403, "The private admin link is required.") def check_settings(settings): slots = sorted(set(settings.slots)) if any(s < 0 or s > 4102444800 or s % 60 for s in slots): raise HTTPException( 422, "Slots must be minute-aligned dates between 1970 and 2100." ) if any(b - a < settings.minutes * 60 for a, b in pairwise(slots)): raise HTTPException(422, "Time slots cannot overlap.") if not set(settings.blocked) <= set(slots): raise HTTPException(422, "Blocked times must belong to the schedule.") return slots, sorted(set(settings.blocked)) @app.get("/healthz") def health(): with db() as con: con.execute("SELECT 1 FROM polls LIMIT 1").fetchone() return {"status": "ok"} @app.get("/api/timezones") def zones(): return sorted(available_timezones()) @app.post("/api/schedule") def preview(schedule: Schedule): try: slots = make_slots( schedule.dates, schedule.start, schedule.end, schedule.minutes, schedule.timezone, ) except ValueError as exc: raise HTTPException(422, str(exc)) from exc if not slots or len(slots) > 12000: raise HTTPException(422, "Choose between 1 and 12,000 slots.") return {"slots": slots} @app.post("/api/resize") def resize(settings: PollSettings, old_minutes: int): if old_minutes not in (15, 30, 60): raise HTTPException(422, "Invalid previous slot size.") slots = resize_slots( sorted(set(settings.slots)), old_minutes, settings.minutes, settings.timezone, ) blocked = remap_votes( {str(s): "yes" for s in settings.blocked}, old_minutes, slots, settings.minutes, ) return {"slots": slots, "blocked": [int(s) for s in blocked]} @app.post("/api/polls", status_code=201) def create(settings: PollSettings): slots, blocked = check_settings(settings) with db(True) as con: while True: code, token = new_code(9), new_code(12) digest = hashlib.sha256(token.encode()).hexdigest() try: con.execute( "INSERT INTO polls(code,admin_hash,title,timezone,fixed_timezone,minutes,slots,blocked) VALUES(?,?,?,?,?,?,?,?)", ( code, digest, settings.title, settings.timezone, settings.fixed_timezone, settings.minutes, json.dumps(slots), json.dumps(blocked), ), ) break except sqlite3.IntegrityError: if ( con.execute( "SELECT 1 FROM polls WHERE code=? OR admin_hash=?", (code, digest), ).fetchone() is None ): raise return {"code": code, "admin_token": token} @app.get("/api/polls/{code}") def get_poll(code: str): with db() as con: poll = load(con, code) poll.pop("admin_hash") poll["people"] = people(con, poll["code"]) active = sorted(set(poll["slots"]) - set(poll["blocked"])) poll["summaries"] = summaries(active, poll["minutes"], poll["people"]) return poll @app.get("/api/polls/{code}/admin") def admin(code: str, request: Request): with db() as con: authorize(request, load(con, code)) return {"authorized": True} @app.post("/api/admin/resolve") def resolve_admin(request: Request): token = normalize_code( request.headers.get("Authorization", "").removeprefix("Bearer ") ) with db() as con: row = con.execute( "SELECT code FROM polls WHERE admin_hash=?", (hashlib.sha256(token.encode()).hexdigest(),), ).fetchone() if row is None: raise HTTPException(403, "The private admin link is invalid.") return {"code": row["code"]} @app.put("/api/polls/{code}") def update(code: str, settings: PollSettings, request: Request): slots, blocked = check_settings(settings) with db(True) as con: poll = load(con, code) authorize(request, poll) if settings.settings_revision != poll["settings_revision"]: raise HTTPException( 409, "This poll was edited elsewhere. Reload before saving." ) active = sorted(set(slots) - set(blocked)) participants = people(con, poll["code"]) if not settings.confirm_loss and any( votes_lost(p["votes"], poll["minutes"], active, settings.minutes) for p in participants ): raise HTTPException( 409, { "code": "vote_loss", "message": "Some existing votes will be lost. Save these changes?", }, ) changed = ( slots != poll["slots"] or blocked != poll["blocked"] or settings.minutes != poll["minutes"] ) if changed: for person in participants: votes = remap_votes( person["votes"], poll["minutes"], active, settings.minutes ) con.execute( "UPDATE people SET votes=?, revision=revision+1 WHERE id=?", (json.dumps(votes), person["id"]), ) con.execute( "UPDATE polls SET title=?, timezone=?, fixed_timezone=?, minutes=?, slots=?, blocked=?, revision=revision+1, settings_revision=settings_revision+1, schedule_revision=schedule_revision+? WHERE code=?", ( settings.title, settings.timezone, settings.fixed_timezone, settings.minutes, json.dumps(slots), json.dumps(blocked), int(changed), poll["code"], ), ) return {"saved": True} def save_person(code, body, person_id=None): name = " ".join(body.name.split()) if not name: raise HTTPException(422, "Enter your name.") name_key = unicodedata.normalize("NFKC", name).casefold() with db(True) as con: poll = load(con, code) if body.schedule_revision != poll["schedule_revision"]: raise HTTPException( 409, "The available times changed. Cancel and review the new schedule before saving.", ) active = {str(s) for s in set(poll["slots"]) - set(poll["blocked"])} if not set(body.votes) <= active or any( v not in ("yes", "maybe") for v in body.votes.values() ): raise HTTPException( 422, "Votes must refer to available slots and use yes or maybe." ) if person_id is not None: person = con.execute( "SELECT * FROM people WHERE id=? AND poll=?", (person_id, poll["code"]), ).fetchone() if person is None or person["revision"] != body.revision: raise HTTPException( 409, "This participant was changed or removed elsewhere. Cancel and reload their votes.", ) elif ( con.execute( "SELECT count(*) FROM people WHERE poll=?", (poll["code"],) ).fetchone()[0] >= 200 ): raise HTTPException( 422, "This poll has reached its limit of 200 participants." ) try: if person_id is None: person_id = con.execute( "INSERT INTO people(poll,name,name_key,votes) VALUES(?,?,?,?)", (poll["code"], name, name_key, json.dumps(body.votes)), ).lastrowid else: con.execute( "UPDATE people SET name=?, name_key=?, votes=?, revision=revision+1 WHERE id=?", (name, name_key, json.dumps(body.votes), person_id), ) except sqlite3.IntegrityError as exc: raise HTTPException( 409, "That name is already in this poll. Choose a different name." ) from exc con.execute( "UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],) ) return {"id": person_id} @app.post("/api/polls/{code}/people", status_code=201) def add_person(code: str, body: Votes): return save_person(code, body) @app.put("/api/polls/{code}/people/{person_id}") def edit_person(code: str, person_id: int, body: Votes): return save_person(code, body, person_id) @app.delete("/api/polls/{code}/people/{person_id}") def delete_person(code: str, person_id: int, revision: int): with db(True) as con: poll = load(con, code) cursor = con.execute( "DELETE FROM people WHERE poll=? AND id=? AND revision=?", (poll["code"], person_id, revision), ) if not cursor.rowcount: raise HTTPException( 409, "This participant was changed or removed elsewhere. Reload before removing them.", ) con.execute( "UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],) ) return {"removed": True} @app.get("/api/polls/{code}/events") async def events(code: str, request: Request): def revision(): with db() as con: return load(con, code)["revision"] await asyncio.to_thread(revision) async def stream(): previous = None while not await request.is_disconnected(): current = await asyncio.to_thread(revision) if current != previous: yield f"data: {current}\n\n" previous = current else: yield ": heartbeat\n\n" await asyncio.sleep(2) return StreamingResponse( stream(), media_type="text/event-stream", headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}, ) app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") @app.get("/") @app.get("/new") @app.get("/{link_code}") def page(): return FileResponse( ROOT / "static" / "index.html", headers={"Cache-Control": "no-cache"} ) return app