import { Hono } from 'hono'; import type { Context } from 'hono'; import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; import path from 'node:path'; import { config } from './config.ts'; import { indexSubmission } from './index-mint.ts'; import { getHealth, getMintDetail, getStats, listMints } from './queries.ts'; import { RATE_LIMIT, takeToken } from './rate-limit.ts'; /** The largest `POST /api/index` body read. A JSON object with two short strings. */ const MAX_BODY_BYTES = 8 * 1024; /** * Who is submitting, for the rate limiter. * * The socket's peer address, unless that peer is the loopback interface — in which case * this process is behind the reverse proxy the README documents, every request has the * same peer, and `X-Forwarded-For` is the only thing that tells two visitors apart. * * The *last* entry of that header, not the first. nginx's `$proxy_add_x_forwarded_for` * appends the peer it actually saw to whatever the client sent, so the first entry is a * value a client can write for itself — a free way around the limit — and the last is * the one the proxy vouched for. When the peer is not loopback the header is ignored * entirely, because then there is no proxy to have vouched for anything. */ function clientKey(c: Context): string { const socket = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined) ?.incoming?.socket; const peer = socket?.remoteAddress ?? ''; const loopback = peer === '' || peer === '::1' || peer === '127.0.0.1' || peer.startsWith('::ffff:127.'); if (!loopback) return peer; const forwarded = c.req.header('x-forwarded-for') ?? ''; const hops = forwarded.split(',').map((hop) => hop.trim()).filter(Boolean); return hops[hops.length - 1] ?? peer ?? 'unknown'; } export function createApp(): Hono { const app = new Hono(); // Public read-only API. There is nothing to protect and every client is a browser. app.use('/api/*', cors()); app.use('/icons/*', cors()); app.get('/api/health', async (c) => { const health = await getHealth(); c.header('Cache-Control', 'no-store'); return c.json(health, health.status === 'ok' ? 200 : 503); }); app.get('/api/stats', async (c) => c.json(await getStats())); /* * The whole index, every ecosystem, each item carrying its `type`. * * `?type=cashu` / `?type=fedimint` narrows it, which is what the two list pages ask * for at build time. Unknown values are passed through rather than rejected: they * return an empty list, which is the honest answer to "show me the ecosystem this * build does not have". */ app.get('/api/mints', async (c) => { const raw = c.req.query('limit'); const limit = raw ? Number.parseInt(raw, 10) : undefined; const type = c.req.query('type')?.trim() || undefined; return c.json(await listMints(Number.isFinite(limit) ? limit : undefined, type)); }); app.get('/api/mints/:host', async (c) => { const detail = await getMintDetail(c.req.param('host')); if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404); return c.json(detail); }); /* * The one endpoint that writes: index a mint nobody has announced yet. * * It is a POST because it creates a row, and it is rate limited because it is the * only route that makes this server fetch an address somebody else chose. Everything * it actually does lives in `index-mint.ts`; what is here is the shape of the request * and the two things that can only be decided at the edge — who is asking, and how * much body to read from them. */ app.post('/api/index', async (c) => { const verdict = takeToken(clientKey(c)); if (!verdict.ok) { c.header('Retry-After', String(verdict.retryAfter)); return c.json( { error: 'rate_limited', message: `At most ${RATE_LIMIT} submissions an hour from one address`, retry_after: verdict.retryAfter, }, 429, ); } // An invite code runs to a few hundred characters; nothing legitimate is near this. const declared = Number(c.req.header('content-length') ?? '0'); if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { return c.json({ error: 'bad_input', message: 'Request body too large' }, 422); } let body: { type?: unknown; input?: unknown }; try { body = (await c.req.json()) as { type?: unknown; input?: unknown }; } catch { return c.json({ error: 'bad_input', message: 'Body must be JSON' }, 422); } const outcome = await indexSubmission(String(body?.type ?? ''), body?.input); if (outcome.status === 429 && 'retry_after' in outcome.body) { c.header('Retry-After', String(outcome.body.retry_after ?? 30)); } return c.json(outcome.body, outcome.status); }); // Cached mint icons, so an offline mint keeps its icon. app.use( '/icons/*', serveStatic({ root: path.relative(process.cwd(), config.iconDir) || '.', rewriteRequestPath: (p) => p.replace(/^\/icons/, ''), onFound: (_p, c) => { c.header('Cache-Control', 'public, max-age=86400'); // These files came from mint operators. nosniff pins the served type, and the // sandbox neutralizes anything script-capable (an SVG cached before icons.ts // stopped accepting them) when the file is opened directly on this origin. c.header('X-Content-Type-Options', 'nosniff'); c.header('Content-Security-Policy', 'sandbox'); }, }), ); app.notFound((c) => c.json({ error: 'not_found' }, 404)); return app; }