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_COOKIE_SECURE=false
STORK_DATA=./data STORK_DATA=./data
# STORK_PUBLIC_URL=https://stork.levkin.ca # 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. name sounds.
Each board has a unique share link (`/b/<uid>`). Start a board with no password, 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). [stork.levkin.ca demo board](https://stork.levkin.ca/b/b_gS0LlZHm-lk).
## Quick start (local) ## 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) - Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras)
- Votes / ranking per board - Votes / ranking per board
- Locale notes (en / ru / he) + voice recordings (re-record overwrites) - 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 ## API sketch
| Method | Path | Notes | | Method | Path | Notes |
|--------|------|-------| |--------|------|-------|
| GET | `/api/health` | Liveness | | GET | `/api/health` | Liveness |
| POST | `/api/boards` | Start board `{title}` → `{id, url}` | | POST | `/api/boards` | Start board `{title}` → `{id, url, readonly}` (rate-limited) |
| GET | `/api/boards/{id}` | Public board title + url | | GET | `/api/boards/{id}` | Public board title + url + readonly |
| GET | `/api/resolve?invite=` | Legacy invite → board url | | 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 | | 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 | | GET/POST | `/api/names` | List / add |
| PATCH | `/api/names/{id}` | Locale notes | | PATCH | `/api/names/{id}` | Locale notes |
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` | | 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` | | DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
## Scripts ## Scripts
```bash ```bash
# Seed starter names onto a board # Seed starter names onto a board (uses session cookies)
make seed STORK_BOARD_ID=b_… make seed STORK_BOARD_ID=b_…
# Demo set (Mira / Noa / Lior / Elena / Ezra): # Demo set (Mira / Noa / Lior / Elena / Ezra):
STORK_SEED_FILE=data/demo-names.example.json make seed STORK_BOARD_ID=b_… 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 = client.get("/api/resolve", params={"invite": invite})
resolved.raise_for_status() resolved.raise_for_status()
board_id = resolved.json()["id"] board_id = resolved.json()["id"]
headers = { session = client.post(
"X-Stork-Board": board_id, "/api/session",
"X-Stork-Display-Name": display, json={"board_id": board_id, "display_name": display},
} )
session.raise_for_status()
existing: dict[tuple[str, str], int] = {} existing: dict[tuple[str, str], int] = {}
for kind in {n.get("kind", "c1") for n in names} | {"c1"}: 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: if listed.status_code != 200:
continue continue
for row in listed.json().get("items") or []: for row in listed.json().get("items") or []:
@@ -60,13 +62,12 @@ def main() -> int:
name_id = existing[key] name_id = existing[key]
res = client.patch( res = client.patch(
f"/api/names/{name_id}", f"/api/names/{name_id}",
headers=headers,
json={"locales": locales}, json={"locales": locales},
) )
res.raise_for_status() res.raise_for_status()
print(f"updated locales {kind}: {spelling}") print(f"updated locales {kind}: {spelling}")
continue continue
res = client.post("/api/names", headers=headers, json=item) res = client.post("/api/names", json=item)
res.raise_for_status() res.raise_for_status()
print(f"added {kind}: {spelling}") print(f"added {kind}: {spelling}")
print(f"board: {base}/b/{board_id}") 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; } .grid-3 { display: grid; gap: 0.55rem; margin-top: 0.55rem; }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } } @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; } .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 { .foot {
margin-top: 1.5rem; padding-top: 0.85rem; margin-top: 1.5rem; padding-top: 0.85rem;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
@@ -372,7 +388,6 @@
<div class="topnav"> <div class="topnav">
<a href="/logos">Logo concepts</a> <a href="/logos">Logo concepts</a>
<a href="/v1">Classic board</a>
</div> </div>
<header class="hero"> <header class="hero">
@@ -384,7 +399,7 @@
</header> </header>
<section id="landing" class="sheet"> <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> <label for="board-title">Board title</label>
<input id="board-title" value="Name board" autocomplete="off" /> <input id="board-title" value="Name board" autocomplete="off" />
<label for="display-new" style="margin-top:0.6rem">Your name</label> <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> <p class="error hidden" id="landing-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem"> <div class="row" style="margin-top:0.65rem">
<button id="start-board" type="button">Start new board</button> <button id="start-board" type="button">Start new board</button>
<button type="button" class="ghost hidden" id="continue-board">Continue</button>
</div> </div>
</section> </section>
@@ -406,14 +422,20 @@
</section> </section>
<section id="board" class="hidden"> <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"> <div class="who">
<span id="who"></span> <span id="who"></span>
<span class="who-actions"> <span class="who-actions">
<button type="button" class="help-chip" data-open-help aria-label="Help" title="Help">?</button> <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> <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> </span>
</div> </div>
<p id="link-risk" class="link-risk hidden">Anyone with this link can edit.</p>
<div class="boards-row"> <div class="boards-row">
<div class="boards" id="boards"></div> <div class="boards" id="boards"></div>
@@ -464,15 +486,16 @@
</div> </div>
</section> </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> </main>
<dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title"> <dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title">
<div class="help-inner"> <div class="help-inner">
<h2 id="help-title">How this board works</h2> <h2 id="help-title">How this board works</h2>
<h3>Sharing</h3> <h3>Sharing</h3>
<ul> <ul>
<li>The <strong>/b/…</strong> link is the key — no password. Use <strong>Copy share link</strong>.</li> <li>The <strong>/b/…</strong> link is the key — no password. Anyone with the link can edit.</li>
<li>Everyone picks a display name; it shows on votes.</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> </ul>
<h3>Names &amp; votes</h3> <h3>Names &amp; votes</h3>
<ul> <ul>
@@ -505,12 +528,14 @@
kind: "c1", kind: "c1",
boardId: null, boardId: null,
boardUrl: null, boardUrl: null,
readonly: false,
columns: [], columns: [],
lists: {}, lists: {},
openId: null, openId: null,
editId: null, editId: null,
langById: {}, langById: {},
recording: null, recording: null,
pendingContinue: null,
}; };
let speakingBtn = null; let speakingBtn = null;
let audioEl = null; let audioEl = null;
@@ -964,9 +989,17 @@
document.getElementById("who").textContent = name; document.getElementById("who").textContent = name;
state.boardId = board && board.id; state.boardId = board && board.id;
state.boardUrl = (board && board.url) || (state.boardId ? `${location.origin}/b/${state.boardId}` : location.href); 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"); const tag = document.getElementById("board-tag");
if (board && board.title) { 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(); if (window.speechSynthesis) speechSynthesis.getVoices();
await loadColumns(); await loadColumns();
@@ -980,7 +1013,23 @@
return m ? decodeURIComponent(m[1]) : ""; 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 () => { document.getElementById("add-col").addEventListener("click", async () => {
if (state.readonly) return;
if (state.columns.length >= MAX_COLUMNS) return; if (state.columns.length >= MAX_COLUMNS) return;
try { try {
const col = await api("/api/columns", { method: "POST" }); 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 () => { document.getElementById("start-board").addEventListener("click", async () => {
const err = document.getElementById("landing-error"); const err = document.getElementById("landing-error");
StorkUI.clearError(err); StorkUI.clearError(err);
@@ -1134,9 +1192,13 @@
try { try {
const session = await api("/api/session"); const session = await api("/api/session");
if (session.authenticated && session.board) { if (session.authenticated && session.board) {
history.replaceState({}, "", `/b/${session.board.id}`); state.pendingContinue = {
await showBoard(session.display_name, session.board); display_name: session.display_name,
return; board: session.board,
};
const btn = document.getElementById("continue-board");
btn.textContent = `Continue to ${session.board.title}`;
btn.classList.remove("hidden");
} }
} catch (_) {} } catch (_) {}
document.getElementById("landing").classList.remove("hidden"); 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 from __future__ import annotations
import hashlib
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from typing import Any 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.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field 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.db import LANGS, MAX_COLUMNS, Store
from stork.rate_limit import limiter
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data")) DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip() INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_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"} 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("/") 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" STATIC = Path(__file__).resolve().parent.parent / "static"
ALLOWED_AUDIO = { ALLOWED_AUDIO = {
@@ -96,6 +102,43 @@ def _admin(
raise HTTPException(403, "Admin token required") 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( def _resolve_board(
board_id: str | None = None, board_id: str | None = None,
invite: str | None = None, invite: str | None = None,
@@ -129,9 +172,6 @@ def _session(
) )
voter = (x_stork_voter or stork_voter or "").strip() voter = (x_stork_voter or stork_voter or "").strip()
name = (x_stork_display_name or stork_name 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: if not voter or not name:
raise HTTPException(401, "Session required — enter your name on this board") raise HTTPException(401, "Session required — enter your name on this board")
return { return {
@@ -171,14 +211,11 @@ def meta() -> dict[str, Any]:
@app.post("/api/boards") @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.""" """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") board = store.create_board(body.title.strip() or "Name board")
return { return _board_public(board)
"id": board["id"],
"title": board["title"],
"url": _board_url(board["id"]),
}
@app.get("/api/boards/{board_id}") @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) board = store.find_board(board_id)
if not board: if not board:
raise HTTPException(404, "Board not found") 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") @app.get("/api/resolve")
@@ -195,11 +232,12 @@ def resolve_invite(invite: str = "") -> dict[str, Any]:
board = store.find_board_by_invite(invite) board = store.find_board_by_invite(invite)
if not board: if not board:
raise HTTPException(404, "Board not found") 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") @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) board = _resolve_board(board_id=body.board_id, invite=body.invite)
voter = secrets.token_urlsafe(16) voter = secrets.token_urlsafe(16)
display = body.display_name.strip() display = body.display_name.strip()
@@ -211,7 +249,7 @@ def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
return { return {
"ok": True, "ok": True,
"display_name": display, "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 { return {
"authenticated": authed, "authenticated": authed,
"display_name": stork_name or "", "display_name": stork_name or "",
"board": ( "board": (_board_public(board) if board else None),
{"id": board["id"], "title": board["title"], "url": _board_url(board["id"])}
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") @app.get("/api/columns")
def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: def list_columns(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
return {"items": store.list_columns(session["board_id"]), "max": MAX_COLUMNS} 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") @app.post("/api/columns")
def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]: def add_column(session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
_assert_writable(session["board_id"])
try: try:
return store.add_column(session["board_id"], "") return store.add_column(session["board_id"], "")
except ValueError as exc: except ValueError as exc:
@@ -263,6 +307,7 @@ def rename_column(
body: ColumnPatch, body: ColumnPatch,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
updated = store.rename_column(session["board_id"], column_id, body.label) updated = store.rename_column(session["board_id"], column_id, body.label)
if not updated: if not updated:
raise HTTPException(404, "Column not found") raise HTTPException(404, "Column not found")
@@ -274,6 +319,7 @@ def delete_column(
column_id: str, column_id: str,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
try: try:
if not store.delete_column(session["board_id"], column_id): if not store.delete_column(session["board_id"], column_id):
raise HTTPException(404, "Column not found") 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") @app.post("/api/names")
def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]: 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()} locales = {k: v.model_dump() for k, v in body.locales.items()}
try: try:
return store.add_name( return store.add_name(
@@ -315,6 +362,7 @@ def patch_name(
body: LocalesPatch, body: LocalesPatch,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
locales = {k: v.model_dump() for k, v in body.locales.items()} locales = {k: v.model_dump() for k, v in body.locales.items()}
updated = store.update_locales(name_id, locales, board_id=session["board_id"]) updated = store.update_locales(name_id, locales, board_id=session["board_id"])
if not updated: if not updated:
@@ -328,6 +376,7 @@ def vote_name(
body: VoteIn, body: VoteIn,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
try: try:
updated = store.vote( updated = store.vote(
name_id=name_id, name_id=name_id,
@@ -347,30 +396,29 @@ def vote_name(
async def upload_recording( async def upload_recording(
name_id: int, name_id: int,
lang: str, lang: str,
request: Request,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
file: UploadFile = File(...), file: UploadFile = File(...),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
_rate_limit(request, "upload")
if lang not in LANGS: if lang not in LANGS:
raise HTTPException(400, "lang must be en, ru, or he") raise HTTPException(400, "lang must be en, ru, or he")
raw = await file.read() raw = await file.read()
content_type = (file.content_type or "audio/webm").split(";")[0].strip().lower() sniffed = sniff_audio(raw)
if content_type not in ALLOWED_AUDIO: if not sniffed:
raise HTTPException(400, f"unsupported audio type: {content_type}") raise HTTPException(400, "unrecognized audio format")
ext = "webm" claimed = (file.content_type or sniffed).split(";")[0].strip().lower()
if "ogg" in content_type: if claimed not in ALLOWED_AUDIO and claimed != sniffed:
ext = "ogg" raise HTTPException(400, f"unsupported audio type: {claimed}")
elif "mp4" in content_type or "m4a" in content_type or "aac" in content_type: content_type = sniffed
ext = "m4a" ext = ext_for_content_type(content_type)
elif "mpeg" in content_type or content_type == "audio/mp3":
ext = "mp3"
elif "wav" in content_type:
ext = "wav"
try: try:
updated = store.save_recording( updated = store.save_recording(
name_id=name_id, name_id=name_id,
lang=lang, lang=lang,
data=raw, data=raw,
content_type=content_type if content_type != "video/webm" else "audio/webm", content_type=content_type,
recorded_by=session["display_name"], recorded_by=session["display_name"],
ext=ext, ext=ext,
board_id=session["board_id"], board_id=session["board_id"],
@@ -403,6 +451,7 @@ def delete_recording(
lang: str, lang: str,
session: dict[str, str] = Depends(_session), session: dict[str, str] = Depends(_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
_assert_writable(session["board_id"])
try: try:
updated = store.delete_recording(name_id, lang, board_id=session["board_id"]) updated = store.delete_recording(name_id, lang, board_id=session["board_id"])
except ValueError as exc: 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): for b in store.list_boards(include_invite=True):
items.append( items.append(
{ {
"id": b["id"], **_board_public(b),
"title": b["title"],
"created_at": b["created_at"], "created_at": b["created_at"],
"url": _board_url(b["id"]),
} }
) )
return {"items": items} 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) board = store.create_board(body.title, body.invite)
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
return { return {**_board_public(board), "created_at": board["created_at"]}
"id": board["id"],
"title": board["title"],
"created_at": board["created_at"],
"url": _board_url(board["id"]),
}
@app.patch("/api/admin/boards/{board_id}") @app.patch("/api/admin/boards/{board_id}")
@@ -460,12 +502,7 @@ def admin_rename_board(
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
if not updated: if not updated:
raise HTTPException(404, "Board not found") raise HTTPException(404, "Board not found")
return { return {**_board_public(updated), "created_at": updated["created_at"]}
"id": updated["id"],
"title": updated["title"],
"created_at": updated["created_at"],
"url": _board_url(updated["id"]),
}
@app.delete("/api/admin/boards/{board_id}") @app.delete("/api/admin/boards/{board_id}")
@@ -498,11 +535,8 @@ def board_page(board_id: str) -> FileResponse:
@app.get("/v1") @app.get("/v1")
def v1() -> FileResponse: def v1() -> RedirectResponse:
index = STATIC / "index.html" return RedirectResponse(url="/", status_code=302)
if not index.is_file():
raise HTTPException(404, "Classic UI missing")
return FileResponse(index)
@app.get("/v2") @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() ).fetchone()
if not rec: if not rec:
return None 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(): if not path.is_file():
return None return None
return path, rec["content_type"] 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_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token") monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false") 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.app as app_mod
import stork.rate_limit as rl_mod
importlib.reload(rl_mod)
importlib.reload(app_mod) importlib.reload(app_mod)
app_mod.limiter.reset()
with TestClient(app_mod.app) as test_client: with TestClient(app_mod.app) as test_client:
yield test_client yield test_client
app_mod.store.close() app_mod.store.close()
+123 -25
View File
@@ -2,8 +2,13 @@
from __future__ import annotations from __future__ import annotations
import importlib
from fastapi.testclient import TestClient 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: def test_health(client: TestClient) -> None:
res = client.get("/api/health") res = client.get("/api/health")
@@ -29,12 +34,19 @@ def test_v2_redirects_home(client: TestClient) -> None:
assert res.headers["location"] == "/" 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: def test_public_create_board_and_share_url(client: TestClient) -> None:
created = client.post("/api/boards", json={"title": "Baby Levkin"}) created = client.post("/api/boards", json={"title": "Baby Levkin"})
assert created.status_code == 200 assert created.status_code == 200
board_id = created.json()["id"] board_id = created.json()["id"]
assert board_id.startswith("b_") assert board_id.startswith("b_")
assert created.json()["url"].endswith(f"/b/{board_id}") assert created.json()["url"].endswith(f"/b/{board_id}")
assert created.json()["readonly"] is False
page = client.get(f"/b/{board_id}") page = client.get(f"/b/{board_id}")
assert page.status_code == 200 assert page.status_code == 200
assert b"Start a private board" in page.content or b"Your name" in page.content 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() b = client.post("/api/boards", json={"title": "B"}).json()
client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"}) 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 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"}) 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 assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
a_names = client.get( b_names = client.get("/api/names?kind=c1").json()["items"]
"/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"]
assert len(a_names) == 1 and len(b_names) == 1 assert len(a_names) == 1 and len(b_names) == 1
assert a_names[0]["id"] != b_names[0]["id"] 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: def test_recording_upload_and_play(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json() created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json()
name_id = created["id"] 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) up = authed.post(f"/api/names/{name_id}/recording/en", files=files)
assert up.status_code == 200 assert up.status_code == 200
assert up.json()["recordings"]["en"] is True assert up.json()["recordings"]["en"] is True
audio = authed.get(f"/api/names/{name_id}/recording/en") audio = authed.get(f"/api/names/{name_id}/recording/en")
assert audio.status_code == 200 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: 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) 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: def test_add_vote_and_rank(authed: TestClient) -> None:
a = authed.post( a = authed.post(
"/api/names", "/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: def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None:
res = client.get("/api/resolve", params={"invite": "test-invite-token"}) res = client.get("/api/resolve", params={"invite": "test-invite-token"})
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["id"] == family_board["id"] 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()