200 lines
6 KiB
JavaScript
200 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;
|
||
|
|
}
|