From 480e5b6ce6b9a84e5c44979bb271986186823da0 Mon Sep 17 00:00:00 2001 From: ilia Date: Fri, 7 Aug 2026 20:55:21 -0400 Subject: [PATCH] Harden Stork for public demo and family durability Read-only demo boards, per-IP rate limits, opaque voter sessions, session leave, upload sniffing, path containment, and retire /v1. --- .env.example | 6 ++ README.md | 20 +++--- scripts/seed_names.py | 15 +++-- static/v2.html | 82 ++++++++++++++++++++--- stork/app.py | 142 +++++++++++++++++++++++++--------------- stork/audio.py | 32 +++++++++ stork/db.py | 6 +- stork/rate_limit.py | 63 ++++++++++++++++++ tests/conftest.py | 5 ++ tests/test_api.py | 148 +++++++++++++++++++++++++++++++++++------- 10 files changed, 414 insertions(+), 105 deletions(-) create mode 100644 stork/audio.py create mode 100644 stork/rate_limit.py diff --git a/.env.example b/.env.example index 61af78f..e8bb8eb 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ STORK_ADMIN_TOKEN=change-me-admin-random STORK_COOKIE_SECURE=false STORK_DATA=./data # STORK_PUBLIC_URL=https://stork.levkin.ca +# Comma-separated board ids that reject member mutations (public demo). +# STORK_READONLY_BOARD_IDS=b_gS0LlZHm-lk +# Optional rate-limit overrides (per IP / hour): +# STORK_RL_BOARDS_PER_HOUR=5 +# STORK_RL_SESSIONS_PER_HOUR=60 +# STORK_RL_UPLOADS_PER_HOUR=30 diff --git a/README.md b/README.md index 2fc2e13..6c04736 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ pronunciation in English, Russian, and Hebrew, and optionally record how a name sounds. Each board has a unique share link (`/b/`). Start a board with no password, -add names, share the link. Live demo: +add names, share the link. Anyone with the link can edit. + +Live read-only demo: [stork.levkin.ca demo board](https://stork.levkin.ca/b/b_gS0LlZHm-lk). ## Quick start (local) @@ -25,29 +27,31 @@ Visitors open the link and enter a display name. - Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras) - Votes / ranking per board - Locale notes (en / ru / he) + voice recordings (re-record overwrites) -- Paper UI at `/` (classic layout at `/v1`) +- Paper UI at `/` +- Optional read-only boards via `STORK_READONLY_BOARD_IDS` ## API sketch | Method | Path | Notes | |--------|------|-------| | GET | `/api/health` | Liveness | -| POST | `/api/boards` | Start board `{title}` → `{id, url}` | -| GET | `/api/boards/{id}` | Public board title + url | +| POST | `/api/boards` | Start board `{title}` → `{id, url, readonly}` (rate-limited) | +| GET | `/api/boards/{id}` | Public board title + url + readonly | | GET | `/api/resolve?invite=` | Legacy invite → board url | -| POST | `/api/session` | `{board_id, display_name}` → cookies | +| POST | `/api/session` | `{board_id, display_name}` → cookies (rate-limited) | +| DELETE | `/api/session` | Clear cookies | | GET | `/api/columns` | Columns for session board | -| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first) | +| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first; blocked if readonly) | | GET/POST | `/api/names` | List / add | | PATCH | `/api/names/{id}` | Locale notes | | POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` | -| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice | +| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice (upload sniffed) | | DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` | ## Scripts ```bash -# Seed starter names onto a board +# Seed starter names onto a board (uses session cookies) make seed STORK_BOARD_ID=b_… # Demo set (Mira / Noa / Lior / Elena / Ezra): STORK_SEED_FILE=data/demo-names.example.json make seed STORK_BOARD_ID=b_… diff --git a/scripts/seed_names.py b/scripts/seed_names.py index 0bc2af1..1425644 100644 --- a/scripts/seed_names.py +++ b/scripts/seed_names.py @@ -39,13 +39,15 @@ def main() -> int: resolved = client.get("/api/resolve", params={"invite": invite}) resolved.raise_for_status() board_id = resolved.json()["id"] - headers = { - "X-Stork-Board": board_id, - "X-Stork-Display-Name": display, - } + session = client.post( + "/api/session", + json={"board_id": board_id, "display_name": display}, + ) + session.raise_for_status() + existing: dict[tuple[str, str], int] = {} for kind in {n.get("kind", "c1") for n in names} | {"c1"}: - listed = client.get("/api/names", params={"kind": kind}, headers=headers) + listed = client.get("/api/names", params={"kind": kind}) if listed.status_code != 200: continue for row in listed.json().get("items") or []: @@ -60,13 +62,12 @@ def main() -> int: name_id = existing[key] res = client.patch( f"/api/names/{name_id}", - headers=headers, json={"locales": locales}, ) res.raise_for_status() print(f"updated locales {kind}: {spelling}") continue - res = client.post("/api/names", headers=headers, json=item) + res = client.post("/api/names", json=item) res.raise_for_status() print(f"added {kind}: {spelling}") print(f"board: {base}/b/{board_id}") diff --git a/static/v2.html b/static/v2.html index 5825c3c..f93d854 100644 --- a/static/v2.html +++ b/static/v2.html @@ -346,6 +346,22 @@ .grid-3 { display: grid; gap: 0.55rem; margin-top: 0.55rem; } @media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } } .hint { color: var(--muted); font-size: 0.9rem; margin: 0.3rem 0 0; } + .banner { + margin: 0 0 0.85rem; padding: 0.55rem 0.75rem; + border: 1px solid var(--rule); background: var(--chip); + color: var(--muted); font-size: 0.88rem; line-height: 1.35; + } + .banner strong { color: var(--ink); } + .link-risk { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.82rem; } + .board-readonly .add-col, + .board-readonly #suggest, + .board-readonly .voice-row, + .board-readonly .votes, + .board-readonly .remove-col, + .board-readonly .title-edit { pointer-events: none; opacity: 0.55; } + .board-readonly .title-edit { border-color: transparent !important; } + .board-readonly .add-col { display: none; } + .board-readonly #suggest { display: none; } .foot { margin-top: 1.5rem; padding-top: 0.85rem; border-top: 1px solid var(--rule); @@ -372,7 +388,6 @@
@@ -384,7 +399,7 @@
-

Start a private board, add names, then share the link. No password — the link is the key.

+

Start a private board, add names, then share the link. No password — the link is the key. Anyone with the link can edit.

@@ -392,6 +407,7 @@
+
@@ -406,14 +422,20 @@ -

Paper board · · press ? · classic layout

+

Paper board · · press ?

How this board works

Sharing

    -
  • The /b/… link is the key — no password. Use Copy share link.
  • -
  • Everyone picks a display name; it shows on votes.
  • +
  • The /b/… link is the key — no password. Anyone with the link can edit.
  • +
  • Use Copy share link. Everyone picks a display name for votes.
  • +
  • Public demo boards are read-only showcases — start your own from home.

Names & votes

    @@ -505,12 +528,14 @@ kind: "c1", boardId: null, boardUrl: null, + readonly: false, columns: [], lists: {}, openId: null, editId: null, langById: {}, recording: null, + pendingContinue: null, }; let speakingBtn = null; let audioEl = null; @@ -964,9 +989,17 @@ document.getElementById("who").textContent = name; state.boardId = board && board.id; state.boardUrl = (board && board.url) || (state.boardId ? `${location.origin}/b/${state.boardId}` : location.href); + state.readonly = !!(board && board.readonly); + const boardEl = document.getElementById("board"); + boardEl.classList.toggle("board-readonly", state.readonly); + document.getElementById("readonly-banner").classList.toggle("hidden", !state.readonly); + document.getElementById("link-risk").classList.toggle("hidden", state.readonly); + document.getElementById("suggest-jump").classList.toggle("hidden", state.readonly); const tag = document.getElementById("board-tag"); if (board && board.title) { - tag.textContent = `${board.title} — vote, edit pronunciation, record your voice when AI misses.`; + tag.textContent = state.readonly + ? `${board.title} — public read-only demo.` + : `${board.title} — vote, edit pronunciation, record your voice when AI misses.`; } if (window.speechSynthesis) speechSynthesis.getVoices(); await loadColumns(); @@ -980,7 +1013,23 @@ return m ? decodeURIComponent(m[1]) : ""; } + async function leaveBoard() { + try { await api("/api/session", { method: "DELETE" }); } catch (_) {} + state.boardId = null; + state.boardUrl = null; + state.readonly = false; + state.pendingContinue = null; + history.replaceState({}, "", "/"); + document.getElementById("board").classList.add("hidden"); + document.getElementById("gate").classList.add("hidden"); + document.getElementById("landing").classList.remove("hidden"); + document.getElementById("continue-board").classList.add("hidden"); + document.getElementById("board-tag").textContent = + "Family name board — vote, edit how a name is said, and record your own voice when TTS misses."; + } + document.getElementById("add-col").addEventListener("click", async () => { + if (state.readonly) return; if (state.columns.length >= MAX_COLUMNS) return; try { const col = await api("/api/columns", { method: "POST" }); @@ -1009,6 +1058,15 @@ } }); + document.getElementById("leave-board").addEventListener("click", () => leaveBoard()); + + document.getElementById("continue-board").addEventListener("click", async () => { + const pending = state.pendingContinue; + if (!pending || !pending.board) return; + history.replaceState({}, "", `/b/${pending.board.id}`); + await showBoard(pending.display_name, pending.board); + }); + document.getElementById("start-board").addEventListener("click", async () => { const err = document.getElementById("landing-error"); StorkUI.clearError(err); @@ -1134,9 +1192,13 @@ try { const session = await api("/api/session"); if (session.authenticated && session.board) { - history.replaceState({}, "", `/b/${session.board.id}`); - await showBoard(session.display_name, session.board); - return; + state.pendingContinue = { + display_name: session.display_name, + board: session.board, + }; + const btn = document.getElementById("continue-board"); + btn.textContent = `Continue to ${session.board.title}`; + btn.classList.remove("hidden"); } } catch (_) {} document.getElementById("landing").classList.remove("hidden"); diff --git a/stork/app.py b/stork/app.py index b0b58ee..ccacff9 100644 --- a/stork/app.py +++ b/stork/app.py @@ -1,25 +1,31 @@ -"""Stork HTTP API + invite-gated multi-board family UI.""" +"""Stork HTTP API + shareable multi-board family UI.""" from __future__ import annotations -import hashlib import os import secrets from pathlib import Path from typing import Any -from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Response, UploadFile +from fastapi import Cookie, Depends, FastAPI, File, Header, HTTPException, Request, Response, UploadFile from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field +from stork.audio import ext_for_content_type, sniff_audio from stork.db import LANGS, MAX_COLUMNS, Store +from stork.rate_limit import limiter DATA_DIR = Path(os.environ.get("STORK_DATA", "./data")) INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip() ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip() COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/") +READONLY_BOARD_IDS = { + x.strip() + for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",") + if x.strip() +} STATIC = Path(__file__).resolve().parent.parent / "static" ALLOWED_AUDIO = { @@ -96,6 +102,43 @@ def _admin( raise HTTPException(403, "Admin token required") +def _client_ip(request: Request) -> str: + forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() + if forwarded: + return forwarded + if request.client and request.client.host: + return request.client.host + return "unknown" + + +def _rate_limit(request: Request, action: str) -> None: + allowed, retry_after = limiter.check(action, _client_ip(request)) + if not allowed: + raise HTTPException( + 429, + "Too many requests — try again later", + headers={"Retry-After": str(retry_after)}, + ) + + +def _is_readonly(board_id: str) -> bool: + return board_id in READONLY_BOARD_IDS + + +def _assert_writable(board_id: str) -> None: + if _is_readonly(board_id): + raise HTTPException(403, "This board is read-only") + + +def _board_public(board: dict[str, Any]) -> dict[str, Any]: + return { + "id": board["id"], + "title": board["title"], + "url": _board_url(board["id"]), + "readonly": _is_readonly(board["id"]), + } + + def _resolve_board( board_id: str | None = None, invite: str | None = None, @@ -129,9 +172,6 @@ def _session( ) voter = (x_stork_voter or stork_voter or "").strip() name = (x_stork_display_name or stork_name or "").strip() - if name and not voter: - digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24] - voter = f"hdr-{digest}" if not voter or not name: raise HTTPException(401, "Session required — enter your name on this board") return { @@ -171,14 +211,11 @@ def meta() -> dict[str, Any]: @app.post("/api/boards") -def create_board_public(body: BoardCreatePublic) -> dict[str, Any]: +def create_board_public(body: BoardCreatePublic, request: Request) -> dict[str, Any]: """Start a new board — no password. Share the returned URL with family.""" + _rate_limit(request, "create_board") board = store.create_board(body.title.strip() or "Name board") - return { - "id": board["id"], - "title": board["title"], - "url": _board_url(board["id"]), - } + return _board_public(board) @app.get("/api/boards/{board_id}") @@ -186,7 +223,7 @@ def get_board_public(board_id: str) -> dict[str, Any]: board = store.find_board(board_id) if not board: raise HTTPException(404, "Board not found") - return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} + return _board_public(board) @app.get("/api/resolve") @@ -195,11 +232,12 @@ def resolve_invite(invite: str = "") -> dict[str, Any]: board = store.find_board_by_invite(invite) if not board: raise HTTPException(404, "Board not found") - return {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} + return _board_public(board) @app.post("/api/session") -def create_session(body: SessionIn, response: Response) -> dict[str, Any]: +def create_session(body: SessionIn, response: Response, request: Request) -> dict[str, Any]: + _rate_limit(request, "session") board = _resolve_board(board_id=body.board_id, invite=body.invite) voter = secrets.token_urlsafe(16) display = body.display_name.strip() @@ -211,7 +249,7 @@ def create_session(body: SessionIn, response: Response) -> dict[str, Any]: return { "ok": True, "display_name": display, - "board": {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}, + "board": _board_public(board), } @@ -236,14 +274,19 @@ def get_session( return { "authenticated": authed, "display_name": stork_name or "", - "board": ( - {"id": board["id"], "title": board["title"], "url": _board_url(board["id"])} - if board - else None - ), + "board": (_board_public(board) if board else None), } +@app.delete("/api/session") +def clear_session(response: Response) -> dict[str, Any]: + response.delete_cookie("stork_board", path="/") + response.delete_cookie("stork_voter", path="/") + response.delete_cookie("stork_name", path="/") + response.delete_cookie("stork_invite", path="/") + return {"ok": True} + + @app.get("/api/columns") def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: return {"items": store.list_columns(session["board_id"]), "max": MAX_COLUMNS} @@ -251,6 +294,7 @@ def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: @app.post("/api/columns") def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + _assert_writable(session["board_id"]) try: return store.add_column(session["board_id"], "") except ValueError as exc: @@ -263,6 +307,7 @@ def rename_column( body: ColumnPatch, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) updated = store.rename_column(session["board_id"], column_id, body.label) if not updated: raise HTTPException(404, "Column not found") @@ -274,6 +319,7 @@ def delete_column( column_id: str, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) try: if not store.delete_column(session["board_id"], column_id): raise HTTPException(404, "Column not found") @@ -296,6 +342,7 @@ def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> @app.post("/api/names") def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + _assert_writable(session["board_id"]) locales = {k: v.model_dump() for k, v in body.locales.items()} try: return store.add_name( @@ -315,6 +362,7 @@ def patch_name( body: LocalesPatch, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) locales = {k: v.model_dump() for k, v in body.locales.items()} updated = store.update_locales(name_id, locales, board_id=session["board_id"]) if not updated: @@ -328,6 +376,7 @@ def vote_name( body: VoteIn, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) try: updated = store.vote( name_id=name_id, @@ -347,30 +396,29 @@ def vote_name( async def upload_recording( name_id: int, lang: str, + request: Request, session: dict[str, str] = Depends(_session), file: UploadFile = File(...), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) + _rate_limit(request, "upload") if lang not in LANGS: raise HTTPException(400, "lang must be en, ru, or he") raw = await file.read() - content_type = (file.content_type or "audio/webm").split(";")[0].strip().lower() - if content_type not in ALLOWED_AUDIO: - raise HTTPException(400, f"unsupported audio type: {content_type}") - ext = "webm" - if "ogg" in content_type: - ext = "ogg" - elif "mp4" in content_type or "m4a" in content_type or "aac" in content_type: - ext = "m4a" - elif "mpeg" in content_type or content_type == "audio/mp3": - ext = "mp3" - elif "wav" in content_type: - ext = "wav" + sniffed = sniff_audio(raw) + if not sniffed: + raise HTTPException(400, "unrecognized audio format") + claimed = (file.content_type or sniffed).split(";")[0].strip().lower() + if claimed not in ALLOWED_AUDIO and claimed != sniffed: + raise HTTPException(400, f"unsupported audio type: {claimed}") + content_type = sniffed + ext = ext_for_content_type(content_type) try: updated = store.save_recording( name_id=name_id, lang=lang, data=raw, - content_type=content_type if content_type != "video/webm" else "audio/webm", + content_type=content_type, recorded_by=session["display_name"], ext=ext, board_id=session["board_id"], @@ -403,6 +451,7 @@ def delete_recording( lang: str, session: dict[str, str] = Depends(_session), ) -> dict[str, Any]: + _assert_writable(session["board_id"]) try: updated = store.delete_recording(name_id, lang, board_id=session["board_id"]) except ValueError as exc: @@ -425,10 +474,8 @@ def admin_list_boards(_: None = Depends(_admin)) -> dict[str, Any]: for b in store.list_boards(include_invite=True): items.append( { - "id": b["id"], - "title": b["title"], + **_board_public(b), "created_at": b["created_at"], - "url": _board_url(b["id"]), } ) return {"items": items} @@ -440,12 +487,7 @@ def admin_create_board(body: BoardCreate, _: None = Depends(_admin)) -> dict[str board = store.create_board(body.title, body.invite) except ValueError as exc: raise HTTPException(400, str(exc)) from exc - return { - "id": board["id"], - "title": board["title"], - "created_at": board["created_at"], - "url": _board_url(board["id"]), - } + return {**_board_public(board), "created_at": board["created_at"]} @app.patch("/api/admin/boards/{board_id}") @@ -460,12 +502,7 @@ def admin_rename_board( raise HTTPException(400, str(exc)) from exc if not updated: raise HTTPException(404, "Board not found") - return { - "id": updated["id"], - "title": updated["title"], - "created_at": updated["created_at"], - "url": _board_url(updated["id"]), - } + return {**_board_public(updated), "created_at": updated["created_at"]} @app.delete("/api/admin/boards/{board_id}") @@ -498,11 +535,8 @@ def board_page(board_id: str) -> FileResponse: @app.get("/v1") -def v1() -> FileResponse: - index = STATIC / "index.html" - if not index.is_file(): - raise HTTPException(404, "Classic UI missing") - return FileResponse(index) +def v1() -> RedirectResponse: + return RedirectResponse(url="/", status_code=302) @app.get("/v2") diff --git a/stork/audio.py b/stork/audio.py new file mode 100644 index 0000000..fcaee63 --- /dev/null +++ b/stork/audio.py @@ -0,0 +1,32 @@ +"""Audio upload sniffing helpers.""" + +from __future__ import annotations + + +def sniff_audio(data: bytes) -> str | None: + """Return a normalized content-type if magic bytes match a known container.""" + if len(data) < 12: + return None + if data[:4] == b"\x1a\x45\xdf\xa3": + return "audio/webm" + if data[:4] == b"OggS": + return "audio/ogg" + if data[:4] == b"RIFF" and data[8:12] == b"WAVE": + return "audio/wav" + if data[:3] == b"ID3" or (data[0] == 0xFF and (data[1] & 0xE0) == 0xE0): + return "audio/mpeg" + if data[4:8] == b"ftyp": + return "audio/mp4" + return None + + +def ext_for_content_type(content_type: str) -> str: + if "ogg" in content_type: + return "ogg" + if "mp4" in content_type or "m4a" in content_type or "aac" in content_type: + return "m4a" + if "mpeg" in content_type or content_type == "audio/mp3": + return "mp3" + if "wav" in content_type: + return "wav" + return "webm" diff --git a/stork/db.py b/stork/db.py index 7c3c76c..989f3c2 100644 --- a/stork/db.py +++ b/stork/db.py @@ -822,7 +822,11 @@ class Store: ).fetchone() if not rec: return None - path = self.audio_dir / rec["path"] + path = (self.audio_dir / rec["path"]).resolve() + try: + path.relative_to(self.audio_dir.resolve()) + except ValueError: + return None if not path.is_file(): return None return path, rec["content_type"] diff --git a/stork/rate_limit.py b/stork/rate_limit.py new file mode 100644 index 0000000..33c3e9b --- /dev/null +++ b/stork/rate_limit.py @@ -0,0 +1,63 @@ +"""In-process sliding-window rate limits (single-container deploy).""" + +from __future__ import annotations + +import os +import threading +import time +from collections import defaultdict, deque + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return max(1, int(raw)) + except ValueError: + return default + + +# action -> (max_hits, window_seconds) +DEFAULTS: dict[str, tuple[int, int]] = { + "create_board": (_env_int("STORK_RL_BOARDS_PER_HOUR", 5), 3600), + "session": (_env_int("STORK_RL_SESSIONS_PER_HOUR", 60), 3600), + "upload": (_env_int("STORK_RL_UPLOADS_PER_HOUR", 30), 3600), +} + + +class RateLimiter: + def __init__(self, limits: dict[str, tuple[int, int]] | None = None) -> None: + self._limits = dict(limits or DEFAULTS) + self._hits: dict[tuple[str, str], deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + self._now = time.monotonic + + def set_clock(self, fn) -> None: + self._now = fn + + def configure(self, action: str, max_hits: int, window_seconds: int) -> None: + self._limits[action] = (max(1, max_hits), max(1, window_seconds)) + + def check(self, action: str, key: str) -> tuple[bool, int]: + """Return (allowed, retry_after_seconds).""" + max_hits, window = self._limits.get(action, (1000, 3600)) + now = self._now() + bucket_key = (action, key) + with self._lock: + q = self._hits[bucket_key] + cutoff = now - window + while q and q[0] < cutoff: + q.popleft() + if len(q) >= max_hits: + retry = max(1, int(window - (now - q[0])) + 1) + return False, retry + q.append(now) + return True, 0 + + def reset(self) -> None: + with self._lock: + self._hits.clear() + + +limiter = RateLimiter() diff --git a/tests/conftest.py b/tests/conftest.py index 34dfae1..e57fb5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,10 +15,15 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token") monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token") monkeypatch.setenv("STORK_COOKIE_SECURE", "false") + monkeypatch.delenv("STORK_READONLY_BOARD_IDS", raising=False) + monkeypatch.delenv("STORK_RL_BOARDS_PER_HOUR", raising=False) import stork.app as app_mod + import stork.rate_limit as rl_mod + importlib.reload(rl_mod) importlib.reload(app_mod) + app_mod.limiter.reset() with TestClient(app_mod.app) as test_client: yield test_client app_mod.store.close() diff --git a/tests/test_api.py b/tests/test_api.py index 5021437..dbf5afd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,8 +2,13 @@ from __future__ import annotations +import importlib + from fastapi.testclient import TestClient +# Minimal EBML/WebM header so upload sniffing accepts the fixture. +WEBM_BYTES = b"\x1a\x45\xdf\xa3" + b"\x00" * 32 + def test_health(client: TestClient) -> None: res = client.get("/api/health") @@ -29,12 +34,19 @@ def test_v2_redirects_home(client: TestClient) -> None: assert res.headers["location"] == "/" +def test_v1_redirects_home(client: TestClient) -> None: + res = client.get("/v1", follow_redirects=False) + assert res.status_code == 302 + assert res.headers["location"] == "/" + + def test_public_create_board_and_share_url(client: TestClient) -> None: created = client.post("/api/boards", json={"title": "Baby Levkin"}) assert created.status_code == 200 board_id = created.json()["id"] assert board_id.startswith("b_") assert created.json()["url"].endswith(f"/b/{board_id}") + assert created.json()["readonly"] is False page = client.get(f"/b/{board_id}") assert page.status_code == 200 assert b"Start a private board" in page.content or b"Your name" in page.content @@ -56,16 +68,10 @@ def test_boards_are_isolated(client: TestClient) -> None: b = client.post("/api/boards", json={"title": "B"}).json() client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"}) assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200 + a_names = client.get("/api/names?kind=c1").json()["items"] client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"}) assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200 - a_names = client.get( - "/api/names?kind=c1", - headers={"X-Stork-Board": a["id"], "X-Stork-Display-Name": "Ilia"}, - ).json()["items"] - b_names = client.get( - "/api/names?kind=c1", - headers={"X-Stork-Board": b["id"], "X-Stork-Display-Name": "Dan"}, - ).json()["items"] + b_names = client.get("/api/names?kind=c1").json()["items"] assert len(a_names) == 1 and len(b_names) == 1 assert a_names[0]["id"] != b_names[0]["id"] @@ -112,13 +118,20 @@ def test_cannot_delete_last_column(authed: TestClient) -> None: def test_recording_upload_and_play(authed: TestClient) -> None: created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json() name_id = created["id"] - files = {"file": ("voice.webm", b"fake-webm-bytes", "audio/webm")} + files = {"file": ("voice.webm", WEBM_BYTES, "audio/webm")} up = authed.post(f"/api/names/{name_id}/recording/en", files=files) assert up.status_code == 200 assert up.json()["recordings"]["en"] is True audio = authed.get(f"/api/names/{name_id}/recording/en") assert audio.status_code == 200 - assert audio.content == b"fake-webm-bytes" + assert audio.content == WEBM_BYTES + + +def test_recording_rejects_bad_magic(authed: TestClient) -> None: + created = authed.post("/api/names", json={"kind": "c1", "spelling": "BadAudio"}).json() + files = {"file": ("voice.webm", b"not-audio", "audio/webm")} + up = authed.post(f"/api/names/{created['id']}/recording/en", files=files) + assert up.status_code == 400 def test_session_rejects_missing_board(client: TestClient) -> None: @@ -131,6 +144,45 @@ def test_names_require_session(client: TestClient) -> None: assert res.status_code in (401, 404) +def test_header_name_alone_rejected(client: TestClient, family_board: dict) -> None: + headers = { + "X-Stork-Board": family_board["id"], + "X-Stork-Display-Name": "Seed Bot", + } + res = client.post( + "/api/names", + headers=headers, + json={"kind": "c1", "spelling": "HeaderOnly"}, + ) + assert res.status_code == 401 + + +def test_header_auth_with_opaque_voter(client: TestClient, family_board: dict) -> None: + headers = { + "X-Stork-Board": family_board["id"], + "X-Stork-Display-Name": "Seed Bot", + "X-Stork-Voter": "opaque-voter-token-1", + } + res = client.post( + "/api/names", + headers=headers, + json={"kind": "c1", "spelling": "HeaderOnly"}, + ) + assert res.status_code == 200 + listed = client.get("/api/names?kind=c1", headers=headers) + assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"]) + + +def test_clear_session(client: TestClient, family_board: dict) -> None: + client.post( + "/api/session", + json={"board_id": family_board["id"], "display_name": "Ilia"}, + ) + assert client.get("/api/session").json()["authenticated"] is True + assert client.delete("/api/session").status_code == 200 + assert client.get("/api/session").json()["authenticated"] is False + + def test_add_vote_and_rank(authed: TestClient) -> None: a = authed.post( "/api/names", @@ -180,22 +232,68 @@ def test_admin_delete_name(authed: TestClient) -> None: ) -def test_header_auth_with_board(client: TestClient, family_board: dict) -> None: - headers = { - "X-Stork-Board": family_board["id"], - "X-Stork-Display-Name": "Seed Bot", - } - res = client.post( - "/api/names", - headers=headers, - json={"kind": "c1", "spelling": "HeaderOnly"}, - ) - assert res.status_code == 200 - listed = client.get("/api/names?kind=c1", headers=headers) - assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"]) - - def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None: res = client.get("/api/resolve", params={"invite": "test-invite-token"}) assert res.status_code == 200 assert res.json()["id"] == family_board["id"] + assert res.json()["readonly"] is False + + +def test_readonly_board_blocks_mutations(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("STORK_DATA", str(tmp_path / "data")) + monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token") + monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token") + monkeypatch.setenv("STORK_COOKIE_SECURE", "false") + monkeypatch.setenv("STORK_READONLY_BOARD_IDS", "") + + import stork.app as app_mod + + importlib.reload(app_mod) + with TestClient(app_mod.app) as client: + board = client.post("/api/boards", json={"title": "Demo"}).json() + board_id = board["id"] + # Reload with this board marked readonly + monkeypatch.setenv("STORK_READONLY_BOARD_IDS", board_id) + importlib.reload(app_mod) + with TestClient(app_mod.app) as client2: + info = client2.get(f"/api/boards/{board_id}") + assert info.status_code == 200 + assert info.json()["readonly"] is True + client2.post( + "/api/session", + json={"board_id": board_id, "display_name": "Visitor"}, + ) + assert ( + client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code + == 403 + ) + assert client2.get("/api/names?kind=c1").status_code == 200 + app_mod.store.close() + # close first store if still open + try: + app_mod.store.close() + except Exception: + pass + + +def test_rate_limit_create_board(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("STORK_DATA", str(tmp_path / "data")) + monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token") + monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token") + monkeypatch.setenv("STORK_COOKIE_SECURE", "false") + monkeypatch.setenv("STORK_RL_BOARDS_PER_HOUR", "2") + + import stork.app as app_mod + import stork.rate_limit as rl + + importlib.reload(rl) + importlib.reload(app_mod) + app_mod.limiter.reset() + app_mod.limiter.configure("create_board", 2, 3600) + with TestClient(app_mod.app) as client: + assert client.post("/api/boards", json={"title": "One"}).status_code == 200 + assert client.post("/api/boards", json={"title": "Two"}).status_code == 200 + third = client.post("/api/boards", json={"title": "Three"}) + assert third.status_code == 429 + assert "Retry-After" in third.headers + app_mod.store.close()