Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
502 lines
19 KiB
Python
502 lines
19 KiB
Python
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
|