Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
236 lines
7.2 KiB
JavaScript
236 lines
7.2 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, paymentsPage,
|
|
} from './admin-pages.js';
|
|
import { allInvoices } from './db.js';
|
|
import {
|
|
manuallyMarkPaid, LISTING_FEE_USD, RECEIVE_ADDRESS, paymentsRequired,
|
|
} from './payments.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);
|
|
if (url === '/admin/payments') return showPayments(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])}`);
|
|
}
|
|
|
|
const markPaidMatch = url.match(/^\/admin\/payments\/([^/]+)\/mark-paid$/);
|
|
if (markPaidMatch && req.method === 'POST') {
|
|
return doMarkPaid(req, res, session, decodeURIComponent(markPaidMatch[1]));
|
|
}
|
|
|
|
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 }));
|
|
}
|
|
|
|
function showPayments(res, session) {
|
|
const invoices = allInvoices();
|
|
const config = {
|
|
required: paymentsRequired(),
|
|
fee_usd: LISTING_FEE_USD,
|
|
address: RECEIVE_ADDRESS,
|
|
};
|
|
renderPage(res, paymentsPage({ session, invoices, config }));
|
|
}
|
|
|
|
/* ----------------- 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 doMarkPaid(req, res, session, invoiceId) {
|
|
const form = await readForm(req);
|
|
try {
|
|
const invoice = manuallyMarkPaid(invoiceId, { txid: form.txid || '', actor: session.name });
|
|
logAudit({
|
|
actor: session.name,
|
|
action: 'mark_invoice_paid',
|
|
target: invoiceId,
|
|
details: { listing_id: invoice.listing_id, txid: form.txid || null, amount_sat: invoice.amount_sat },
|
|
});
|
|
} catch (err) {
|
|
// Fall through to the redirect; the admin can retry from the UI.
|
|
}
|
|
res.writeHead(302, { location: '/admin/payments' });
|
|
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;
|
|
}
|