/admin is wallet-gated. The configured ADMIN_PUBLISHER is a BNS name; only the on-chain owner of that name can sign in. The sign-in flow is BIP-137 (same primitive publishers use): the login page shows a fresh one-shot challenge, the admin signs it in their wallet, pastes the signature, and the service verifies that it recovers to the current on-chain owner. Session is a 24-hour ed25519-signed cookie minted by the service key. Pages: /admin dashboard — totals, mode state, recent listings + audit /admin/login BIP-137 challenge sign-in /admin/listings all listings, with a Withdraw button per row /admin/modes global + per-category publish-mode toggle /admin/audit the hash-chained audit log The three publish modes exist as toggles: `append` — any valid listing goes in (launch default) `scan-first` — infected uploads are rejected before they land `review` — clean listings wait for human approval Global mode is the fallback; a per-category override takes precedence when set. The toggle persists; the review-queue enforcement lands in a later milestone (the enforcement sketch is in modes.js's header). Audit log: Every admin action (login, set_mode, clear_mode, withdraw_listing) is an append-only row with a sha256 chain over the previous row's hash. The chain does not prevent edits; it makes retroactive ones loud — row N's `prev_hash` disagreeing with row N-1's `this_hash` is the tamper signal. Withdrawal: Soft — `withdrawn_at` on the listing row. Browse hides withdrawn listings (already the case) and the detail page returns 404 for them. Nothing in the chain is undone; this is Prometheus's own view only. Session design: Cookie is `prom_admin=<payload b64url>.<signature b64url>`, signed with the ed25519 service key, HttpOnly, SameSite=Strict, Path=/admin, 24-hour expiry. Reconfiguring ADMIN_PUBLISHER invalidates in-flight sessions by name mismatch. Challenges live in a 10-min TTL map and are tied to a specific name so one admin's challenge cannot authenticate another. Added: service/admin.js route handlers + dispatch service/admin-pages.js HTML templates for the four pages service/admin-session.js challenge + cookie mint/verify + readSession service/audit.js hash-chained append helper service/modes.js publish-mode read/write with override semantics schema: settings + audit tables scripts/test-admin-roundtrip.mjs drives the whole flow (unauth redirect → challenge scrape → sign → login → dashboard → set mode → audit inspection) with the same deterministic test key the listing-roundtrip script uses. Withdraw-flow spot-check: create a listing, log in as admin, withdraw it, confirm browse hides it and the detail page returns 404. All seven checks pass.
199 lines
6 KiB
JavaScript
199 lines
6 KiB
JavaScript
/**
|
|
* Admin route handlers.
|
|
*
|
|
* Everything here is form-based so a JS-off browser still works. The one GET
|
|
* that mutates state (the login POST → session cookie) is a form submission;
|
|
* every other mutation POSTs and redirects to the GET it changed.
|
|
*
|
|
* Pages come from admin-pages.js; this file is the glue between HTTP and the
|
|
* DB / session.
|
|
*/
|
|
|
|
import { layout } from './layout.js';
|
|
import {
|
|
newChallenge, verifyLogin, readSession, clearCookie, adminPublisher, loginMessage,
|
|
} from './admin-session.js';
|
|
import {
|
|
allListingsIncludingWithdrawn, allActiveListings, withdrawListing, getListing,
|
|
countActive, recentAudit as recentAuditRows,
|
|
} from './db.js';
|
|
import { allModes, setMode, MODES } from './modes.js';
|
|
import { CATEGORIES, countsByCategory } from './pages-browse.js';
|
|
import { logAudit } from './audit.js';
|
|
import {
|
|
loginPage, dashboardPage, listingsPage, modesPage, auditPage,
|
|
} from './admin-pages.js';
|
|
|
|
/* ----------------- dispatch (called from server.js) ----------------- */
|
|
|
|
/**
|
|
* Admin router. Returns true if it handled the request. The caller falls
|
|
* through to the regular router if we return false.
|
|
*/
|
|
export async function handleAdmin(req, res, url) {
|
|
// /admin/login (GET + POST) is the only path accessible without a session.
|
|
if (url === '/admin/login') {
|
|
if (req.method === 'POST') return doLogin(req, res);
|
|
return renderPage(res, loginPage({
|
|
challenge: newChallenge(adminPublisher()),
|
|
name: adminPublisher() || '(ADMIN_PUBLISHER env not set)',
|
|
}));
|
|
}
|
|
if (url === '/admin/logout' && req.method === 'POST') {
|
|
return doLogout(res);
|
|
}
|
|
|
|
// Everything else requires a session.
|
|
const session = readSession(req);
|
|
if (!session) {
|
|
res.writeHead(302, { location: '/admin/login' });
|
|
return res.end();
|
|
}
|
|
|
|
if (url === '/admin' || url === '/admin/') return showDashboard(res, session);
|
|
if (url === '/admin/listings') return showListings(res, session);
|
|
if (url === '/admin/modes') {
|
|
if (req.method === 'POST') return saveModes(req, res, session);
|
|
return showModes(res, session);
|
|
}
|
|
if (url === '/admin/audit') return showAudit(res, session);
|
|
|
|
const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/);
|
|
if (withdrawMatch && req.method === 'POST') {
|
|
return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`);
|
|
}
|
|
|
|
return notFoundHtml(res);
|
|
}
|
|
|
|
/* ----------------- login + logout ----------------- */
|
|
|
|
async function doLogin(req, res) {
|
|
const form = await readForm(req);
|
|
const result = await verifyLogin({
|
|
challenge: String(form.challenge || ''),
|
|
signature: String(form.signature || ''),
|
|
});
|
|
if (!result.ok) {
|
|
return renderPage(res, loginPage({
|
|
challenge: newChallenge(adminPublisher()),
|
|
name: adminPublisher(),
|
|
error: result.reason,
|
|
}), 400);
|
|
}
|
|
|
|
logAudit({ actor: result.admin.name, action: 'login' });
|
|
|
|
res.writeHead(302, {
|
|
location: '/admin',
|
|
'set-cookie': result.cookie,
|
|
});
|
|
res.end();
|
|
}
|
|
|
|
function doLogout(res) {
|
|
res.writeHead(302, {
|
|
location: '/admin/login',
|
|
'set-cookie': clearCookie(),
|
|
});
|
|
res.end();
|
|
}
|
|
|
|
/* ----------------- GET pages ----------------- */
|
|
|
|
function showDashboard(res, session) {
|
|
const counts = countsByCategory();
|
|
const total = countActive();
|
|
const modes = allModes(CATEGORIES.map((c) => c.slug));
|
|
const recentListings = allActiveListings().slice(0, 10);
|
|
const recentAudit = recentAuditRows(20);
|
|
renderPage(res, dashboardPage({ session, counts, total, modes, recentListings, recentAudit }));
|
|
}
|
|
|
|
function showListings(res, session) {
|
|
const rows = allListingsIncludingWithdrawn();
|
|
renderPage(res, listingsPage({ session, rows }));
|
|
}
|
|
|
|
function showModes(res, session) {
|
|
const modes = allModes(CATEGORIES.map((c) => c.slug));
|
|
renderPage(res, modesPage({ session, modes }));
|
|
}
|
|
|
|
function showAudit(res, session) {
|
|
const entries = recentAuditRows(200);
|
|
renderPage(res, auditPage({ session, entries }));
|
|
}
|
|
|
|
/* ----------------- POST actions ----------------- */
|
|
|
|
async function doWithdraw(req, res, session, id) {
|
|
const row = getListing(id);
|
|
if (!row) return notFoundHtml(res);
|
|
const changed = withdrawListing(id, new Date().toISOString());
|
|
if (changed) {
|
|
logAudit({
|
|
actor: session.name,
|
|
action: 'withdraw_listing',
|
|
target: id,
|
|
details: { category: row.category, publisher: row.publisher },
|
|
});
|
|
}
|
|
res.writeHead(302, { location: '/admin/listings' });
|
|
res.end();
|
|
}
|
|
|
|
async function saveModes(req, res, session) {
|
|
const form = await readForm(req);
|
|
|
|
// Global first — the per-category reads depend on the current global.
|
|
const g = String(form.global || '');
|
|
if (MODES.includes(g)) {
|
|
setMode({ category: null, value: g, actor: session.name });
|
|
}
|
|
|
|
for (const c of CATEGORIES) {
|
|
const v = String(form[`cat_${c.slug}`] || '');
|
|
// v === '' means "inherits global" — pass empty value to clear override.
|
|
setMode({ category: c.slug, value: v, actor: session.name });
|
|
}
|
|
|
|
res.writeHead(302, { location: '/admin/modes' });
|
|
res.end();
|
|
}
|
|
|
|
/* ----------------- helpers ----------------- */
|
|
|
|
function renderPage(res, page, status = 200) {
|
|
const html = layout(page);
|
|
res.writeHead(status, {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'cache-control': 'no-store',
|
|
});
|
|
res.end(html);
|
|
}
|
|
|
|
function notFoundHtml(res) {
|
|
const page = {
|
|
title: 'Not here',
|
|
description: 'The requested admin page does not exist.',
|
|
body: `<header class="hero compact"><h1>Not here</h1></header>
|
|
<div class="wrap"><section class="plain"><p>Go <a href="/admin">home</a>.</p></section></div>`,
|
|
};
|
|
renderPage(res, page, 404);
|
|
}
|
|
|
|
async function readForm(req) {
|
|
const chunks = [];
|
|
let total = 0;
|
|
for await (const chunk of req) {
|
|
total += chunk.length;
|
|
if (total > 64 * 1024) throw new Error('form too large');
|
|
chunks.push(chunk);
|
|
}
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
const params = new URLSearchParams(body);
|
|
const out = {};
|
|
for (const [k, v] of params) out[k] = v;
|
|
return out;
|
|
}
|