Cap uploads, GC empty boards, drop classic UI
CI / secret-scan (pull_request) Successful in 32s
CI / python-ci (pull_request) Successful in 1m9s

Close residual audit P2: 5 MiB recording limit, opportunistic orphan
board GC, and remove static/index.html.
This commit is contained in:
2026-08-07 21:12:47 -04:00
parent 8eb289c991
commit 66818f500b
7 changed files with 167 additions and 590 deletions
+4
View File
@@ -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
+5 -1
View File
@@ -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
-568
View File
@@ -1,568 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<script src="/static/stork-common.js"></script>
<script>StorkUI.applyTheme();</script>
<title>Stork — family name board</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root, [data-theme="light"] {
--sky: #d6e4ef; --fog: #eef3f7; --paper: #f8faf8; --ink: #18253a; --muted: #4a5566;
--line: #c5d3e0; --accent: #2a5f7a; --wing: #b85c48; --up: #2f6b5a; --down: #9a4a3a;
--rank: #1e3a55; --shadow: 0 10px 32px rgba(24,37,58,.07); --radius: 16px;
--input-bg: #fff; --panel: #f8faf8; --logo-filter: none;
--body-bg: radial-gradient(900px 420px at 85% -5%, #f0d8cf 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, var(--sky) 0%, transparent 50%),
linear-gradient(180deg, #e7eef4 0%, var(--fog) 45%, #e9eee8 100%);
}
[data-theme="dark"] {
--sky: #1a2433; --fog: #121820; --paper: #161c26; --ink: #e8eef6; --muted: #a8b4c4;
--line: #2e3a4c; --accent: #7eb6d1; --wing: #e0a090; --up: #5fad93; --down: #d48474;
--rank: #c5d4e8; --shadow: 0 10px 32px rgba(0,0,0,.35);
--input-bg: #0f141c; --panel: #1a222e; --logo-filter: invert(1) contrast(1.05);
--body-bg: radial-gradient(900px 420px at 85% -5%, #2a2030 0%, transparent 55%),
radial-gradient(1100px 520px at -5% 0%, #1a2433 0%, transparent 50%),
linear-gradient(180deg, #10151c 0%, #121820 45%, #141a22 100%);
}
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--paper);padding:.5rem .75rem;z-index:10}
.skip:focus{left:.5rem;top:.5rem}
.theme-bar{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;margin:0 0 .85rem;justify-content:flex-end}
.theme-bar span{color:var(--muted);font-size:.9rem;margin-right:.25rem}
.theme-bar button{padding:.35rem .65rem;font-size:.85rem;min-height:2.25rem;background:var(--input-bg);color:var(--ink);border:1px solid var(--line)}
.theme-bar button.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.error{color:var(--down);margin:.45rem 0 0;font-size:.95rem;font-weight:600;border-left:3px solid var(--down);padding-left:.55rem}
input[aria-invalid="true"]{border-color:var(--down);box-shadow:0 0 0 1px var(--down)}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: "Source Sans 3", system-ui, sans-serif;
background: var(--body-bg);
min-height: 100vh;
}
main { max-width: 1000px; margin: 0 auto; padding: 1.25rem 1rem 3.5rem; }
.hero {
display: flex;
gap: 0.85rem;
align-items: center;
margin-bottom: 1rem;
}
.hero img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line);
background: var(--input-bg); filter: var(--logo-filter);
flex: 0 0 auto;
}
.brand {
font-family: Fraunces, Georgia, serif;
font-size: clamp(1.9rem, 5vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
line-height: 1;
}
.sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.95rem; }
.sub a { color: var(--accent); font-weight: 600; text-decoration: none; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 0.85rem;
box-shadow: var(--shadow);
}
label { display: block; font-size: 0.95rem; color: var(--muted); margin-bottom: 0.25rem; font-weight: 600; }
input, textarea, button {
font: inherit;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 11px;
border: 1px solid var(--line);
background: var(--input-bg);
color: var(--ink);
}
textarea { min-height: 2.8rem; resize: vertical; }
button {
width: auto;
cursor: pointer;
background: var(--accent);
color: #fff;
border-color: transparent;
font-weight: 600;
}
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 2px solid var(--wing);
outline-offset: 2px;
}
button.secondary { background: var(--input-bg); color: var(--ink); border-color: var(--line); }
button.icon {
width: 2rem; height: 2rem; padding: 0;
display: inline-grid; place-items: center;
border-radius: 999px; background: var(--input-bg); color: var(--accent);
border: 1px solid var(--line); flex: 0 0 auto;
}
button.icon.playing { background: var(--accent); color: #fff; }
button.vote {
min-width: 2.2rem; padding: 0.3rem 0.45rem;
background: var(--input-bg); color: var(--ink); border: 1px solid var(--line);
}
button.vote.active-up { background: var(--up); color: #fff; border-color: transparent; }
button.vote.active-down { background: var(--down); color: #fff; border-color: transparent; }
button.pill {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--input-bg);
color: var(--muted);
border: 1px solid var(--line);
}
button.pill.active { background: var(--ink); color: var(--paper); border-color: transparent; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
.who {
display: flex; justify-content: space-between; gap: 0.75rem;
align-items: baseline; flex-wrap: wrap; margin-bottom: 0.75rem;
color: var(--muted); font-size: 0.92rem;
}
.who a { color: var(--accent); font-weight: 600; text-decoration: none; }
.boards {
display: flex; gap: 0.85rem; margin-bottom: 0.85rem;
overflow-x: auto; -webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory; overscroll-behavior-x: contain;
padding-bottom: 0.25rem;
}
.boards > section {
flex: 0 0 auto;
width: min(18.5rem, calc(100vw - 2.5rem));
scroll-snap-align: start;
}
@media (min-width: 860px) {
.boards > section { flex: 1 1 0; width: auto; min-width: 16rem; }
}
.col-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.35rem; padding-bottom: 0.45rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
font-family: Fraunces, Georgia, serif;
font-size: 1.2rem; margin: 0; font-weight: 700;
}
.col-head .count { color: var(--muted); font-size: 0.95rem; font-weight: 600; }
.name-card {
display: grid;
grid-template-columns: 2.2rem 1fr auto;
gap: 0.55rem;
align-items: start;
padding: 0.7rem 0.1rem;
border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
}
.name-card:first-of-type { border-top: 0; }
.score {
font-family: Fraunces, Georgia, serif;
font-variant-numeric: tabular-nums;
font-weight: 700; font-size: 1.2rem; color: var(--rank);
text-align: center; padding-top: 0.1rem;
}
.spelling {
font-family: Fraunces, Georgia, serif;
font-size: 1.25rem; margin: 0 0 0.1rem; letter-spacing: -0.02em;
}
.meta { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.4rem; }
.say-row {
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.say {
font-family: Fraunces, Georgia, serif;
font-size: 1.05rem; margin: 0; min-width: 3rem;
}
.pills { display: flex; gap: 0.25rem; }
details.more { margin-top: 0.25rem; }
details.more summary {
cursor: pointer; color: var(--accent); font-weight: 600; font-size: 0.85rem;
list-style: none;
}
details.more summary::-webkit-details-marker { display: none; }
.note {
margin: 0.4rem 0 0; padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--sky) 30%, white);
border-radius: 10px; color: var(--muted); font-size: 0.86rem;
}
.votes { display: flex; flex-direction: column; gap: 0.25rem; }
.error { color: var(--down); margin: 0.45rem 0 0; }
.hidden { display: none !important; }
.empty { color: var(--muted); padding: 0.55rem 0.1rem; font-size: 0.92rem; }
.compose-head {
display: flex; flex-wrap: wrap; gap: 0.65rem; align-items: end;
justify-content: space-between; margin-bottom: 0.75rem;
}
.compose-head h2 {
font-family: Fraunces, Georgia, serif; margin: 0; font-size: 1.15rem;
}
.kind-toggle { display: flex; gap: 0.3rem; }
.kind-toggle button {
min-width: 5.5rem;
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
.kind-toggle button.active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.kind-toggle button:not(.active):hover {
border-color: var(--accent);
}
.grid-3 { display: grid; gap: 0.55rem; }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
.hint { color: var(--muted); font-size: 0.95rem; margin: 0.25rem 0 0.55rem; }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<main id="main">
<div class="theme-bar" role="group" aria-label="Color theme">
<span>Theme</span>
<button type="button" data-theme-toggle="light">Light</button>
<button type="button" data-theme-toggle="dark">Dark</button>
<button type="button" data-theme-toggle="system">System</button>
</div>
<header class="hero">
<img src="/static/logo-mark.png" alt="Stork" width="56" height="56" />
<div>
<h1 class="brand">Stork</h1>
<p class="sub">Classic layout · <a href="/">Paper board</a> · <a href="/logos">Logo ideas</a></p>
</div>
</header>
<section id="gate" class="panel">
<label for="invite">Invite code</label>
<input id="invite" autocomplete="off" />
<label for="display" style="margin-top:0.65rem">Your name</label>
<input id="display" autocomplete="nickname" />
<p class="error hidden" id="gate-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="enter" type="button">Enter</button>
</div>
</section>
<section id="board" class="hidden">
<div class="who">
<span id="who"></span>
<a href="#suggest">Suggest a name</a>
</div>
<div class="boards">
<section class="panel" aria-labelledby="first-heading">
<div class="col-head">
<h2 id="first-heading">First</h2>
<span class="count" id="first-count"></span>
</div>
<div id="list-first"></div>
</section>
<section class="panel" aria-labelledby="middle-heading">
<div class="col-head">
<h2 id="middle-heading">Middle</h2>
<span class="count" id="middle-count"></span>
</div>
<div id="list-middle"></div>
</section>
</div>
<div class="panel" id="suggest">
<div class="compose-head">
<h2>Suggest a name</h2>
<div class="kind-toggle" role="group" aria-label="Name type">
<button type="button" class="secondary active" data-kind="c1">First</button>
<button type="button" class="secondary" data-kind="c2">Middle</button>
</div>
</div>
<label for="spelling">Spelling</label>
<input id="spelling" required aria-required="true" autocomplete="off" />
<p class="hint">Pronunciation fields: just the name in that language (e.g. Roze → שושנה).</p>
<div class="grid-3">
<div>
<label>English</label>
<input data-loc="en" data-field="pronunciation" placeholder="Name" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="en" data-field="origin" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea>
</div>
<div>
<label>Russian</label>
<input data-loc="ru" data-field="pronunciation" placeholder="Имя" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="ru" data-field="origin" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea>
</div>
<div>
<label>Hebrew</label>
<input data-loc="he" data-field="pronunciation" placeholder="שם" dir="auto" />
<label style="margin-top:0.4rem">Origin</label>
<input data-loc="he" data-field="origin" dir="auto" />
<label style="margin-top:0.4rem">Meaning</label>
<textarea data-loc="he" data-field="meaning" dir="auto"></textarea>
</div>
</div>
<p class="error hidden" id="add-error" role="alert" aria-live="polite"></p>
<div class="row" style="margin-top:0.65rem">
<button id="add" type="button">Add</button>
</div>
</div>
</section>
</main>
<script>
const state = { kind: "c1", c1: [], c2: [], langById: {} };
const SPEECH_LANG = { en: "en-US", ru: "ru-RU", he: "he-IL" };
let speakingBtn = null;
function params() { return new URLSearchParams(location.search); }
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const detail = (data && (data.detail || data.message)) || res.statusText;
throw new Error(StorkUI.formatError(detail));
}
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function stopSpeech() {
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (speakingBtn) { speakingBtn.classList.remove("playing"); speakingBtn = null; }
}
function play(lang, text, btn) {
if (!window.speechSynthesis) {
alert("Speech not supported in this browser.");
return;
}
const say = (text || "").trim();
if (!say) return;
stopSpeech();
const u = new SpeechSynthesisUtterance(say);
u.lang = SPEECH_LANG[lang] || "en-US";
u.rate = 0.92;
const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang === u.lang)
|| voices.find((v) => v.lang && v.lang.startsWith(u.lang.slice(0, 2)));
if (match) u.voice = match;
speakingBtn = btn;
btn.classList.add("playing");
u.onend = u.onerror = () => { btn.classList.remove("playing"); speakingBtn = null; };
speechSynthesis.speak(u);
}
function formFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return (L.pronunciation || "").trim() || (lang === "en" ? n.spelling : "");
}
function noteFor(n, lang) {
const L = (n.locales && n.locales[lang]) || {};
return [L.origin, L.meaning].filter(Boolean).join(" — ");
}
function renderCard(n, kind) {
const lang = state.langById[n.id] || "en";
const form = formFor(n, lang);
const note = noteFor(n, lang);
const pills = ["en", "ru", "he"].map((l) =>
`<button type="button" class="pill ${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
).join("");
return `
<article class="name-card" data-id="${n.id}" data-kind="${kind}">
<div class="score">${n.score}</div>
<div>
<p class="spelling" dir="auto">${escapeHtml(n.spelling)}</p>
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}</p>
<div class="say-row">
<div class="pills">${pills}</div>
<p class="say" dir="auto">${escapeHtml(form || "—")}</p>
<button type="button" class="icon" data-play
data-lang="${lang}" data-text="${escapeHtml(form)}"
aria-label="Play">▶</button>
</div>
${note ? `<details class="more"><summary>Meaning</summary><p class="note" dir="auto">${escapeHtml(note)}</p></details>` : ""}
</div>
<div class="votes">
<button type="button" class="vote ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="vote ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`;
}
function bindCard(root, items) {
root.querySelectorAll("button[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const card = btn.closest(".name-card");
const id = card.dataset.id;
let value = Number(btn.dataset.vote);
const item = items.find((x) => String(x.id) === String(id));
if (item && item.my_vote === value) value = 0;
await api(`/api/names/${id}/vote`, { method: "POST", body: JSON.stringify({ value }) });
await refresh();
});
});
root.querySelectorAll("button[data-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.closest(".name-card").dataset.id);
state.langById[id] = btn.dataset.lang;
const kind = btn.closest(".name-card").dataset.kind;
renderList(kind, kind === "c1" ? state.c1 : state.c2);
});
});
root.querySelectorAll("button[data-play]").forEach((btn) => {
btn.addEventListener("click", () => play(btn.dataset.lang, btn.dataset.text, btn));
});
}
function renderList(kind, items) {
const root = document.getElementById(kind === "c1" ? "list-first" : "list-middle");
const count = document.getElementById(kind === "c1" ? "first-count" : "middle-count");
count.textContent = String(items.length);
if (!items.length) {
root.innerHTML = `<p class="empty">None yet</p>`;
return;
}
root.innerHTML = items.map((n) => renderCard(n, kind)).join("");
bindCard(root, items);
}
function localePayload() {
const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
});
return locales;
}
function clearForm() {
document.getElementById("spelling").value = "";
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
}
async function refresh() {
const first = await api("/api/names?kind=c1");
state.c1 = first.items || [];
state.c2 = [];
renderList("c1", state.c1);
const midRoot = document.getElementById("list-middle");
const midCount = document.getElementById("middle-count");
if (midRoot) midRoot.innerHTML = `<p class="empty">Use the <a href="/">paper board</a> for more columns.</p>`;
if (midCount) midCount.textContent = "—";
}
async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = displayName;
if (window.speechSynthesis) speechSynthesis.getVoices();
await refresh();
}
document.querySelectorAll(".kind-toggle button").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.classList.contains("active") ? "true" : "false");
btn.addEventListener("click", () => {
state.kind = btn.dataset.kind;
document.querySelectorAll(".kind-toggle button").forEach((b) => {
const on = b === btn;
b.classList.toggle("active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
});
});
document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error");
StorkUI.clearError(err);
try {
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({
invite: document.getElementById("invite").value.trim(),
display_name: document.getElementById("display").value.trim(),
}),
});
const url = new URL(location.href);
url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname + (url.search || ""));
await showBoard(data.display_name);
} catch (e) {
StorkUI.showError(err, e.message);
}
});
document.getElementById("add").addEventListener("click", async () => {
const err = document.getElementById("add-error");
const spellingEl = document.getElementById("spelling");
StorkUI.clearError(err);
spellingEl.removeAttribute("aria-invalid");
const spelling = spellingEl.value.trim();
if (!spelling) {
StorkUI.showError(err, "Enter a name spelling");
spellingEl.setAttribute("aria-invalid", "true");
spellingEl.focus();
return;
}
try {
await api("/api/names", {
method: "POST",
body: JSON.stringify({
kind: state.kind,
spelling,
locales: localePayload(),
}),
});
clearForm();
await refresh();
} catch (e) {
StorkUI.showError(err, e.message);
spellingEl.setAttribute("aria-invalid", "true");
}
});
StorkUI.initThemeControls();
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => speechSynthesis.getVoices();
(async () => {
const invite = params().get("invite");
if (invite) document.getElementById("invite").value = invite;
try {
const session = await api("/api/session");
if (session.authenticated) await showBoard(session.display_name);
} catch (_) {}
})();
</script>
</body>
</html>
+36 -1
View File
@@ -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")
+57 -20
View File
@@ -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(
+2
View File
@@ -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
+63
View File
@@ -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