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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user