srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-15 20:44:00 +0200
committersrdusr <[email protected]>2025-12-15 20:44:00 +0200
commit1740327557074df0c8b99635ee949e2540ac94d0 (patch)
treeeb88af6056350798fa3f53ff4349b1e947c71d6e /web/src/screens
parent3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (diff)
downloadtyperpunk-1740327557074df0c8b99635ee949e2540ac94d0.tar.gz
typerpunk-1740327557074df0c8b99635ee949e2540ac94d0.zip
Harden for production: dependencies, headers, admin roles, docs
Dependencies - The server build carried 37 known advisories, including RUSTSEC-2024-0363 in sqlx 0.7, which is the database layer. sqlx moved to 0.8 with default-features off, which also drops the MySQL and SQLite drivers and with them rsa and RUSTSEC-2023-0071. reqwest moved to 0.12, which brings hyper 1.x and was the sole source of every remaining advisory: h2 0.3, rustls-webpki 0.101, rustls-pemfile 1.0 and idna 0.3. - The server build now reports no known vulnerabilities against OSV. cargo audit itself would not compile, so the check queries OSV with the crate versions cargo tree reports for the server binary. - Cargo.lock is committed. This workspace produces binaries, so the lockfile is what makes a deployed build reproducible and the audit above meaningful. Headers - The application sent no security headers at all. The static server now sends a Content-Security-Policy, nosniff, frame options, a referrer policy and a permissions policy; the API sends a policy of its own, since it serves JSON and should load and frame nothing. - The one inline script in index.html moved to a file so script-src needs no unsafe-inline. WebAssembly needs wasm-unsafe-eval, without which nothing types at all, so that is present and explained. - Five style attributes moved to the CSSOM rather than adding unsafe-inline for styles. A style attribute in markup is refused by the policy; the same property set through element.style is not. Production configuration - With TYPERPUNK_ENV=production the server refuses to start if COOKIE_SECURE is off, if DATABASE_URL is still the development default, or if FRONTEND_ORIGIN is http on a non-local host. These were warnings, and a warning in a log nobody reads is not a safeguard. Administration - Moderators were appointed with psql. There is now an admin role, bootstrapped from TYPERPUNK_ADMIN_USERNAME at startup, and a UI to appoint and remove moderators. An administrator's own role cannot be changed through the API, so a mistake cannot lock everyone out of moderation. Corpus - scripts/export_approved.js writes approved submissions back into data/packs/community-*.json. Approved passages are served from the database and merged at startup, so without this the repository dataset and the live corpus drift apart, and a fresh checkout or the TUI sees only what shipped. Documentation - README rewritten for the repository: what it does, how to run it, the pack format, the server variables, deployment, and what the security posture actually is. Plain English, no em dashes, no emoji. Checked and found already correct: every private endpoint refuses anonymous callers, session cookies are HttpOnly and SameSite=Lax, CORS names a single origin, internal errors are logged rather than returned, and every query is parameterised.
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/contributeScreen.js49
-rw-r--r--web/src/screens/endScreen.js7
-rw-r--r--web/src/screens/multiplayerScreen.js20
-rw-r--r--web/src/screens/storeScreen.js8
4 files changed, 79 insertions, 5 deletions
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js
index 4464ae2..4c47852 100644
--- a/web/src/screens/contributeScreen.js
+++ b/web/src/screens/contributeScreen.js
@@ -24,6 +24,9 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
let mine = [];
let queue = [];
let isModerator = false;
+ let isAdmin = false;
+ let roleHolders = [];
+ let userSearch = [];
let cleanupInner = null;
function statusLabel(s) {
@@ -39,6 +42,10 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
// case rather than an error worth showing.
try { queue = await api.get('/api/texts/queue'); isModerator = true; }
catch { queue = []; isModerator = false; }
+ // Same shape: a 401 here means "not an administrator", which is the
+ // ordinary case for almost everyone.
+ try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; }
+ catch { roleHolders = []; isAdmin = false; }
}
function submissionRow(s, moderating) {
@@ -88,6 +95,26 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
<div class="submission-list">${mine.map(s => submissionRow(s, false)).join('')}</div>
` : ''}
+ ${isAdmin ? `
+ <h3>Moderators</h3>
+ <div class="settings-hint">Moderators review submitted passages. An administrator's own role is managed by the server, not here.</div>
+ <form class="account-form admin-search-form">
+ <input class="account-input admin-search" type="text" placeholder="Find a user by name" autocomplete="off">
+ <button class="menu-button small" type="submit">Search</button>
+ </form>
+ <div class="submission-list admin-user-list">
+ ${(userSearch.length ? userSearch : roleHolders).map(u => `
+ <div class="admin-user" data-username="${escapeHtml(u.username)}">
+ <span class="admin-user-name">${escapeHtml(u.username)}</span>
+ ${u.is_admin ? '<span class="admin-user-role">admin</span>' : ''}
+ ${u.is_moderator && !u.is_admin ? '<span class="admin-user-role">moderator</span>' : ''}
+ ${u.is_bot ? '<span class="admin-user-role">bot</span>' : ''}
+ ${u.is_admin ? '' : `<button class="menu-button small ${u.is_moderator ? 'quiet' : ''}" data-action="set-role" data-username="${escapeHtml(u.username)}" data-moderator="${u.is_moderator ? 'false' : 'true'}">${u.is_moderator ? 'Remove' : 'Make moderator'}</button>`}
+ </div>
+ `).join('') || '<div class="stats-empty">No moderators yet. Search for a user to appoint one.</div>'}
+ </div>
+ ` : ''}
+
${isModerator ? `
<h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3>
${queue.length
@@ -135,6 +162,28 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
});
}
+ const searchForm = root.querySelector('.admin-search-form');
+ if (searchForm) {
+ searchForm.addEventListener('submit', async e => {
+ e.preventDefault();
+ const q = searchForm.querySelector('.admin-search').value.trim();
+ try { userSearch = q ? await api.get(`/api/admin/users?q=${encodeURIComponent(q)}`) : []; }
+ catch { userSearch = []; }
+ render();
+ });
+ }
+ root.querySelectorAll('[data-action="set-role"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try {
+ await api.post(`/api/admin/users/${encodeURIComponent(btn.dataset.username)}/role`,
+ { moderator: btn.dataset.moderator === 'true' });
+ } catch { /* reloading below shows whatever actually happened */ }
+ userSearch = [];
+ await load();
+ render();
+ });
+ });
+
root.querySelectorAll('.submission-actions button').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.submission-row');
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index 086407c..b03c306 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -102,7 +102,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
<span></span><span>Racer</span><span>WPM</span><span>Acc</span><span>Time</span>
</div>
` + list.map(r => `
- <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" style="--racer-color: ${r.color || 'var(--primary-color)'}">
+ <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" data-racer-color="${r.color || 'var(--primary-color)'}">
<span class="mp-standings-place">${r.place ? r.place : '&middot;'}</span>
<span class="mp-standings-name">${escapeHtml(r.name)}${r.me ? ' (you)' : ''}</span>
<span class="mp-standings-wpm">${Math.round(r.wpm)}</span>
@@ -110,6 +110,11 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
<span class="mp-standings-time">${r.place && r.time != null ? `${r.time.toFixed(1)}s` : 'racing'}</span>
</div>
`).join('');
+ // See multiplayerScreen.js: a style attribute is blocked by the CSP,
+ // the same property set through element.style is not.
+ standingsRows.querySelectorAll('[data-racer-color]').forEach(el => {
+ el.style.setProperty('--racer-color', el.dataset.racerColor);
+ });
}
if (standings && standings.length) paintStandings(standings);
const offStandings = onStandingsUpdate ? onStandingsUpdate(paintStandings) : null;
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index ba051c6..073d3cc 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -26,6 +26,18 @@ function racerColors(players, myId) {
return colors;
}
+// A style="" attribute in markup is refused by the Content-Security-Policy;
+// the same property set through element.style is not. Markup carries the
+// value in a data attribute and this applies it.
+function applyInlineStyles(container) {
+ container.querySelectorAll('[data-racer-color]').forEach(el => {
+ el.style.setProperty('--racer-color', el.dataset.racerColor);
+ });
+ container.querySelectorAll('[data-percent]').forEach(el => {
+ el.style.width = `${el.dataset.percent}%`;
+ });
+}
+
// No account required - a quick race with friends shouldn't need signing
// in, so this only prefills the name field when one exists rather than
// gating the whole feature behind it (unlike Friends/Leaderboard, which
@@ -166,11 +178,12 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
function paintPlayers() {
const colors = racerColors(players, myId);
listEl.innerHTML = players.map(p => `
- <div class="leaderboard-row" style="--racer-color: ${colors[p.id]}">
+ <div class="leaderboard-row" data-racer-color="${colors[p.id]}">
<div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div>
<div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div>
</div>
`).join('');
+ applyInlineStyles(listEl);
}
paintPlayers();
@@ -251,12 +264,13 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
const me = p.id === myId;
const prog = progressById[p.id] || {};
return `
- <div class="mp-racer-row${me ? ' me' : ''}" style="--racer-color: ${colors[p.id]}">
+ <div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}">
<span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span>
- <div class="mp-racer-bar"><div class="mp-racer-bar-fill" style="width:${prog.percent || 0}%"></div></div>
+ <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div>
<span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span>
</div>`;
}).join('');
+ applyInlineStyles(opponents);
}
paintOpponents();
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 5a533d9..d1675a7 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -31,7 +31,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
const owned = !signedOut && mine.owned.includes(item.id);
const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id);
const swatch = item.category === 'caret'
- ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>`
+ ? `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>`
: `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`;
return `
<div class="leaderboard-row store-item-row">
@@ -72,6 +72,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ // The swatch colour comes from the catalogue, so it cannot be a class.
+ // Set through element.style because a style attribute is blocked by the
+ // Content-Security-Policy.
+ root.querySelectorAll('[data-swatch-colour]').forEach(el => {
+ el.style.background = el.dataset.swatchColour;
+ });
attachTooltips(root);
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });