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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_…
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user