Harden Stork for public demo and family durability
CI / secret-scan (pull_request) Successful in 33s
CI / python-ci (pull_request) Successful in 1m8s

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:
2026-08-07 20:55:21 -04:00
parent da0419d9d3
commit 480e5b6ce6
10 changed files with 414 additions and 105 deletions
+72 -10
View File
@@ -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 &amp; 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");