From 66818f500b4162785b0836dde752b118ed807910 Mon Sep 17 00:00:00 2001 From: ilia Date: Fri, 7 Aug 2026 21:12:47 -0400 Subject: [PATCH] Cap uploads, GC empty boards, drop classic UI Close residual audit P2: 5 MiB recording limit, opportunistic orphan board GC, and remove static/index.html. --- .env.example | 4 + README.md | 6 +- static/index.html | 568 ---------------------------------------------- stork/app.py | 37 ++- stork/db.py | 77 +++++-- tests/conftest.py | 2 + tests/test_api.py | 63 +++++ 7 files changed, 167 insertions(+), 590 deletions(-) delete mode 100644 static/index.html diff --git a/.env.example b/.env.example index e8bb8eb..51211f3 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ STORK_DATA=./data # STORK_RL_BOARDS_PER_HOUR=5 # STORK_RL_SESSIONS_PER_HOUR=60 # STORK_RL_UPLOADS_PER_HOUR=30 +# Max recording upload size in bytes (default 5 MiB). +# STORK_MAX_UPLOAD_BYTES=5242880 +# Delete empty boards older than this many hours (on create). Default 48. +# STORK_ORPHAN_BOARD_HOURS=48 diff --git a/README.md b/README.md index 6c04736..4c9adfc 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,13 @@ Visitors open the link and enter a display name. | 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 (upload sniffed) | +| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice (magic sniff; max ~5 MiB) | | DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` | +Empty unused boards (no names) older than `STORK_ORPHAN_BOARD_HOURS` (default 48) +are removed when someone creates a new board. Readonly + invite Family boards are +kept. + ## Scripts ```bash diff --git a/static/index.html b/static/index.html deleted file mode 100644 index a07902d..0000000 --- a/static/index.html +++ /dev/null @@ -1,568 +0,0 @@ - - - - - - - - - Stork — family name board - - - - - - - - - -
-
- Theme - - - -
- -
- Stork -
-

Stork

-

Classic layout · Paper board · Logo ideas

-
-
- -
- - - - - -
- -
-
- - -
- - - diff --git a/stork/app.py b/stork/app.py index ccacff9..4a6f627 100644 --- a/stork/app.py +++ b/stork/app.py @@ -26,6 +26,22 @@ READONLY_BOARD_IDS = { for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",") if x.strip() } + + +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 + + +# Recording upload hard cap (bytes read into memory). +MAX_UPLOAD_BYTES = _env_int("STORK_MAX_UPLOAD_BYTES", 5 * 1024 * 1024) +# Empty boards older than this are deleted opportunistically on create. +ORPHAN_BOARD_HOURS = _env_int("STORK_ORPHAN_BOARD_HOURS", 48) STATIC = Path(__file__).resolve().parent.parent / "static" ALLOWED_AUDIO = { @@ -210,10 +226,27 @@ def meta() -> dict[str, Any]: } +def _protected_board_ids() -> set[str]: + protect = set(READONLY_BOARD_IDS) + if INVITE_TOKEN: + family = store.find_board_by_invite(INVITE_TOKEN) + if family: + protect.add(family["id"]) + return protect + + +def _gc_orphan_boards() -> None: + store.gc_empty_boards( + older_than_seconds=float(ORPHAN_BOARD_HOURS) * 3600.0, + protect_ids=_protected_board_ids(), + ) + + @app.post("/api/boards") 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") + _gc_orphan_boards() board = store.create_board(body.title.strip() or "Name board") return _board_public(board) @@ -404,7 +437,9 @@ async def upload_recording( _rate_limit(request, "upload") if lang not in LANGS: raise HTTPException(400, "lang must be en, ru, or he") - raw = await file.read() + raw = await file.read(MAX_UPLOAD_BYTES + 1) + if len(raw) > MAX_UPLOAD_BYTES: + raise HTTPException(413, f"recording too large (max {MAX_UPLOAD_BYTES} bytes)") sniffed = sniff_audio(raw) if not sniffed: raise HTTPException(400, "unrecognized audio format") diff --git a/stork/db.py b/stork/db.py index 989f3c2..035afc5 100644 --- a/stork/db.py +++ b/stork/db.py @@ -381,26 +381,63 @@ class Store: def delete_board(self, board_id: str) -> bool: with self._lock: - count = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] - if count <= 1: - raise ValueError("keep at least one board") - name_ids = [ - r["id"] - for r in self._conn.execute( - "SELECT id FROM names WHERE board_id = ?", (board_id,) - ).fetchall() - ] - for name_id in name_ids: - recs = self._conn.execute( - "SELECT path FROM recordings WHERE name_id = ?", (name_id,) - ).fetchall() - for rec in recs: - path = self.audio_dir / rec["path"] - if path.is_file(): - path.unlink() - cur = self._conn.execute("DELETE FROM boards WHERE id = ?", (board_id,)) - self._conn.commit() - return cur.rowcount > 0 + return self._delete_board_unlocked(board_id) + + def _delete_board_unlocked(self, board_id: str) -> bool: + count = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] + if count <= 1: + raise ValueError("keep at least one board") + name_ids = [ + r["id"] + for r in self._conn.execute( + "SELECT id FROM names WHERE board_id = ?", (board_id,) + ).fetchall() + ] + for name_id in name_ids: + recs = self._conn.execute( + "SELECT path FROM recordings WHERE name_id = ?", (name_id,) + ).fetchall() + for rec in recs: + path = self.audio_dir / rec["path"] + if path.is_file(): + path.unlink() + cur = self._conn.execute("DELETE FROM boards WHERE id = ?", (board_id,)) + self._conn.commit() + return cur.rowcount > 0 + + def gc_empty_boards( + self, + *, + older_than_seconds: float, + protect_ids: set[str] | None = None, + ) -> list[str]: + """Delete boards with no names older than the cutoff (except protected ids).""" + protect = set(protect_ids or ()) + cutoff = time.time() - max(0.0, older_than_seconds) + deleted: list[str] = [] + with self._lock: + rows = self._conn.execute( + """ + SELECT b.id FROM boards b + WHERE b.created_at < ? + AND NOT EXISTS (SELECT 1 FROM names n WHERE n.board_id = b.id) + ORDER BY b.created_at ASC + """, + (cutoff,), + ).fetchall() + for row in rows: + board_id = row["id"] + if board_id in protect: + continue + remaining = self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] + if remaining <= 1: + break + try: + if self._delete_board_unlocked(board_id): + deleted.append(board_id) + except ValueError: + break + return deleted def _column_ids(self, board_id: str) -> set[str]: rows = self._conn.execute( diff --git a/tests/conftest.py b/tests/conftest.py index e57fb5a..ce0b19d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: monkeypatch.setenv("STORK_COOKIE_SECURE", "false") monkeypatch.delenv("STORK_READONLY_BOARD_IDS", raising=False) monkeypatch.delenv("STORK_RL_BOARDS_PER_HOUR", raising=False) + monkeypatch.delenv("STORK_MAX_UPLOAD_BYTES", raising=False) + monkeypatch.delenv("STORK_ORPHAN_BOARD_HOURS", raising=False) import stork.app as app_mod import stork.rate_limit as rl_mod diff --git a/tests/test_api.py b/tests/test_api.py index dbf5afd..164736c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -134,6 +134,69 @@ def test_recording_rejects_bad_magic(authed: TestClient) -> None: assert up.status_code == 400 +def test_recording_rejects_oversized(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_MAX_UPLOAD_BYTES", "40") + + import stork.app as app_mod + import stork.rate_limit as rl + + importlib.reload(rl) + importlib.reload(app_mod) + app_mod.limiter.reset() + with TestClient(app_mod.app) as client: + board = client.get("/api/resolve", params={"invite": "test-invite-token"}).json() + client.post("/api/session", json={"board_id": board["id"], "display_name": "Ilia"}) + name = client.post("/api/names", json={"kind": "c1", "spelling": "Big"}).json() + huge = b"\x1a\x45\xdf\xa3" + b"\x00" * 64 + up = client.post( + f"/api/names/{name['id']}/recording/en", + files={"file": ("voice.webm", huge, "audio/webm")}, + ) + assert up.status_code == 413 + app_mod.store.close() + + +def test_orphan_empty_boards_gc(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_ORPHAN_BOARD_HOURS", "1") + + import time + + import stork.app as app_mod + import stork.rate_limit as rl + + importlib.reload(rl) + importlib.reload(app_mod) + app_mod.limiter.reset() + with TestClient(app_mod.app) as client: + family = client.get("/api/resolve", params={"invite": "test-invite-token"}).json() + orphan = client.post("/api/boards", json={"title": "Abandoned"}).json() + orphan_id = orphan["id"] + # Backdate orphan so it is past the 1h cutoff. + with app_mod.store._lock: + app_mod.store._conn.execute( + "UPDATE boards SET created_at = ? WHERE id = ?", + (time.time() - 7200, orphan_id), + ) + app_mod.store._conn.commit() + kept = client.post("/api/boards", json={"title": "Fresh"}).json() + assert client.get(f"/api/boards/{orphan_id}").status_code == 404 + assert client.get(f"/api/boards/{kept['id']}").status_code == 200 + assert client.get(f"/api/boards/{family['id']}").status_code == 200 + app_mod.store.close() + + +def test_classic_index_removed(client: TestClient) -> None: + assert client.get("/static/index.html").status_code == 404 + + def test_session_rejects_missing_board(client: TestClient) -> None: res = client.post("/api/session", json={"display_name": "Ilia", "board_id": "b_missing"}) assert res.status_code == 404