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 @@
-
-
-
-
-
- Theme
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Suggest a name
-
-
-
-
-
-
-
-
Pronunciation fields: just the name in that language (e.g. Roze → שושנה).
-
-
-
-
-
-
-
-
-
-
-
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