Harden Stork for public demo and family durability
CI / secret-scan (pull_request) Successful in 33s
CI / python-ci (pull_request) Successful in 1m8s

Read-only demo boards, per-IP rate limits, opaque voter sessions,
session leave, upload sniffing, path containment, and retire /v1.
This commit is contained in:
2026-08-07 20:55:21 -04:00
parent da0419d9d3
commit 480e5b6ce6
10 changed files with 414 additions and 105 deletions
+6
View File
@@ -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
+12 -8
View File
@@ -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/<uid>`). 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_…
+8 -7
View File
@@ -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}")
+72 -10
View File
@@ -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 @@
<div class="topnav">
<a href="/logos">Logo concepts</a>
<a href="/v1">Classic board</a>
</div>
<header class="hero">
@@ -384,7 +399,7 @@
</header>
<section id="landing" class="sheet">
<p class="hint" style="margin-top:0">Start a private board, add names, then share the link. No password — the link is the key.</p>
<p class="hint" style="margin-top:0">Start a private board, add names, then share the link. No password — the link is the key. Anyone with the link can edit.</p>
<label for="board-title">Board title</label>
<input id="board-title" value="Name board" autocomplete="off" />
<label for="display-new" style="margin-top:0.6rem">Your name</label>
@@ -392,6 +407,7 @@
<p class="error hidden" id="landing-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="start-board" type="button">Start new board</button>
<button type="button" class="ghost hidden" id="continue-board">Continue</button>
</div>
</section>
@@ -406,14 +422,20 @@
</section>
<section id="board" class="hidden">
<p id="readonly-banner" class="banner hidden" role="status">
<strong>Public demo (read-only).</strong> Browse the sample names, then
<a href="/">start your own board</a> from home.
</p>
<div class="who">
<span id="who"></span>
<span class="who-actions">
<button type="button" class="help-chip" data-open-help aria-label="Help" title="Help">?</button>
<button type="button" class="ghost" id="share-link" style="padding:0.35rem 0.6rem;font-size:0.85rem">Copy share link</button>
<a href="#suggest">Suggest a name</a>
<button type="button" class="ghost" id="leave-board" style="padding:0.35rem 0.6rem;font-size:0.85rem">Leave board</button>
<a href="#suggest" id="suggest-jump">Suggest a name</a>
</span>
</div>
<p id="link-risk" class="link-risk hidden">Anyone with this link can edit.</p>
<div class="boards-row">
<div class="boards" id="boards"></div>
@@ -464,15 +486,16 @@
</div>
</section>
<p class="foot">Paper board · <button type="button" class="help-link" data-open-help>Help</button> · press <kbd>?</kbd> · <a href="/v1">classic layout</a></p>
<p class="foot">Paper board · <button type="button" class="help-link" data-open-help>Help</button> · press <kbd>?</kbd></p>
</main>
<dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title">
<div class="help-inner">
<h2 id="help-title">How this board works</h2>
<h3>Sharing</h3>
<ul>
<li>The <strong>/b/…</strong> link is the key — no password. Use <strong>Copy share link</strong>.</li>
<li>Everyone picks a display name; it shows on votes.</li>
<li>The <strong>/b/…</strong> link is the key — no password. Anyone with the link can edit.</li>
<li>Use <strong>Copy share link</strong>. Everyone picks a display name for votes.</li>
<li>Public demo boards are <strong>read-only</strong> showcases — start your own from home.</li>
</ul>
<h3>Names &amp; votes</h3>
<ul>
@@ -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");
+88 -54
View File
@@ -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")
+32
View File
@@ -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"
+5 -1
View File
@@ -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"]
+63
View File
@@ -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()
+5
View File
@@ -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()
+123 -25
View File
@@ -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()