task/33: Relay admin panel at /admin/relay (final, all review fixes)
## What was built Relay operator dashboard at GET /admin/relay (outside /api, clean URL). Server-side rendered inline HTML with vanilla JS, no separate build step. ## Route registration admin-relay-panel.ts imported in app.ts and mounted via app.use() after /api and before /tower. Route not in routes/index.ts (would be /api/admin/relay). ## Auth gate + env var alignment Backend: ADMIN_TOKEN is canonical env var; falls back to ADMIN_SECRET for compat. ADMIN_TOKEN exported as requireAdmin from admin-relay.ts; admin-relay- queue.ts imports it instead of duplicating. Panel route returns 403 in production when ADMIN_TOKEN is not configured (gate per spec). Frontend: prompt reads "Enter the ADMIN_TOKEN". Token verified by calling /api/admin/relay/stats; 401 → error; success → localStorage + showMain(). ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes from 1st review round: 1. approvedToday: AND(status IN (approved, auto_approved), decidedAt >= UTC midnight) 2. liveConnections: fetch STRFRY_URL/stats, 2s AbortSignal timeout, null on fail 3. Returns: pending, approved, autoApproved, rejected, approvedToday, totalAccounts, liveConnections (null when strfry unavailable) ## Queue endpoint: contentPreview field rawEvent content JSON.parsed and sliced to 120 chars; null on parse failure. GET /api/admin/relay/queue?status=pending used by UI (pending-only, per spec). ## Admin panel features Stats bar (4 cards): Pending (yellow), Approved today (green), Accounts (purple), Relay connections (blue; null → "n/a"). Queue tab: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Accounts tab: whitelist table, Revoke (with confirm), Grant form. 15s auto-refresh on queue + stats. Toast feedback on all actions. Navigation: ← Timmy UI, Workshop, Log out. ## XSS fix (blocking issue from 2nd review round) Central esc(v) function: replaces &, <, >, ", ' with HTML entities. Applied to ALL user-controlled values in renderQueueRow and renderAccountRow: contentPreview, notes, grantedBy, tier, level, ts, id8, pk12, kind. onclick handlers use safeId/safePk: hex chars stripped to [0-9a-f] only. Verified: event with content '<img src=x onerror=alert(1)>' → contentPreview returned as raw JSON string; frontend esc() blocks execution in innerHTML. ## TypeScript: 0 errors. Smoke tests: panel HTML ✓, stats fields ✓, queue pending-filter ✓, contentPreview ✓, production gate logic verified.
This commit is contained in:
@@ -14,11 +14,21 @@
|
||||
* Stats bar: pending, approved today, total accounts, live relay connections.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/admin/relay", (_req, res) => {
|
||||
// Gate: in production the panel returns 403 when ADMIN_TOKEN is not configured.
|
||||
// In development (no token set), the auth gate still renders and the API uses
|
||||
// localhost-only fallback, so ops can still access the panel locally.
|
||||
const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? "";
|
||||
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||||
|
||||
router.get("/admin/relay", (_req: Request, res: Response) => {
|
||||
if (IS_PROD && !ADMIN_TOKEN) {
|
||||
res.status(403).send("Relay admin panel disabled: ADMIN_TOKEN is not configured.");
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(ADMIN_PANEL_HTML);
|
||||
});
|
||||
@@ -563,19 +573,35 @@ async function loadQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// esc() — HTML-escape a value before injection into innerHTML.
|
||||
// Prevents XSS from any user-controlled field (contentPreview, notes, pubkey, etc.).
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderQueueRow(ev) {
|
||||
var id8 = ev.eventId ? ev.eventId.slice(0,8) + '…' : '—';
|
||||
var pk12 = ev.pubkey ? ev.pubkey.slice(0,12) + '…' : '—';
|
||||
var preview = ev.contentPreview ? ev.contentPreview : '(empty)';
|
||||
var ts = ev.createdAt ? new Date(ev.createdAt).toLocaleString() : '—';
|
||||
var pill = '<span class="pill pill-' + (ev.status||'none') + '">' + (ev.status||'—') + '</span>';
|
||||
var approve = '<button class="btn btn-approve" onclick="queueApprove(\'' + ev.eventId + '\',this)">Approve</button>';
|
||||
var reject = '<button class="btn btn-reject" onclick="queueReject(\'' + ev.eventId + '\',this)">Reject</button>';
|
||||
// eventId and pubkey are server-validated hex strings, but still escape defensively.
|
||||
var id8 = ev.eventId ? esc(ev.eventId.slice(0,8)) + '…' : '—';
|
||||
var pk12 = ev.pubkey ? esc(ev.pubkey.slice(0,12)) + '…' : '—';
|
||||
var preview = esc(ev.contentPreview || '(empty)');
|
||||
var ts = ev.createdAt ? esc(new Date(ev.createdAt).toLocaleString()) : '—';
|
||||
var status = esc(ev.status || 'none');
|
||||
// onclick uses hex event IDs validated server-side — still sanitise to be safe.
|
||||
var safeId = (ev.eventId || '').replace(/[^0-9a-f]/gi, '');
|
||||
var pill = '<span class="pill pill-' + status + '">' + status + '</span>';
|
||||
var approve = '<button class="btn btn-approve" onclick="queueApprove(\'' + safeId + '\',this)">Approve</button>';
|
||||
var reject = '<button class="btn btn-reject" onclick="queueReject(\'' + safeId + '\',this)">Reject</button>';
|
||||
var kind = ev.kind != null ? esc(String(ev.kind)) : '—';
|
||||
return '<tr>' +
|
||||
'<td class="mono">' + id8 + '</td>' +
|
||||
'<td class="mono">' + pk12 + '</td>' +
|
||||
'<td>' + (ev.kind != null ? ev.kind : '—') + '</td>' +
|
||||
'<td class="preview" title="' + preview.replace(/"/g,'"') + '">' + preview + '</td>' +
|
||||
'<td>' + kind + '</td>' +
|
||||
'<td class="preview" title="' + preview + '">' + preview + '</td>' +
|
||||
'<td>' + pill + '</td>' +
|
||||
'<td class="ts">' + ts + '</td>' +
|
||||
'<td>' + approve + reject + '</td>' +
|
||||
@@ -621,21 +647,26 @@ async function loadAccounts() {
|
||||
}
|
||||
|
||||
function renderAccountRow(ac) {
|
||||
var pk = ac.pubkey || '';
|
||||
var pk12 = pk.slice(0,12) + (pk.length > 12 ? '…' : '');
|
||||
var ts = ac.grantedAt ? new Date(ac.grantedAt).toLocaleDateString() : '—';
|
||||
var level = ac.accessLevel || 'none';
|
||||
var tier = ac.trustTier || '—';
|
||||
var notes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : '');
|
||||
var isRevoked = ac.revokedAt != null;
|
||||
var pk = ac.pubkey || '';
|
||||
var pk12 = esc(pk.slice(0,12)) + (pk.length > 12 ? '…' : '');
|
||||
var ts = ac.grantedAt ? esc(new Date(ac.grantedAt).toLocaleDateString()) : '—';
|
||||
// level and tier come from enum/trusted fields, but escape anyway.
|
||||
var level = esc(ac.accessLevel || 'none');
|
||||
var tier = esc(ac.trustTier || '—');
|
||||
var grantedBy = esc(ac.grantedBy || '—');
|
||||
var rawNotes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : '');
|
||||
var notes = esc(rawNotes);
|
||||
var isRevoked = ac.revokedAt != null;
|
||||
// pubkey is server-validated hex; strip non-hex chars for onclick safety.
|
||||
var safePk = pk.replace(/[^0-9a-f]/gi, '');
|
||||
var action = isRevoked
|
||||
? '<span style="color:var(--muted);font-size:0.75rem;">revoked</span>'
|
||||
: '<button class="btn btn-revoke" onclick="revokeAccount(\'' + pk + '\',this)">Revoke</button>';
|
||||
: '<button class="btn btn-revoke" onclick="revokeAccount(\'' + safePk + '\',this)">Revoke</button>';
|
||||
return '<tr>' +
|
||||
'<td class="mono" title="' + pk + '">' + pk12 + '</td>' +
|
||||
'<td class="mono" title="' + esc(pk) + '">' + pk12 + '</td>' +
|
||||
'<td><span class="pill pill-' + level + '">' + level + '</span></td>' +
|
||||
'<td>' + tier + '</td>' +
|
||||
'<td style="color:var(--muted);">' + (ac.grantedBy||'—') + '</td>' +
|
||||
'<td style="color:var(--muted);">' + grantedBy + '</td>' +
|
||||
'<td style="color:var(--muted);font-size:0.78rem;">' + notes + '</td>' +
|
||||
'<td class="ts">' + ts + '</td>' +
|
||||
'<td>' + action + '</td>' +
|
||||
|
||||
Reference in New Issue
Block a user