// Admin — dashboard, stats API, metrics UI, and the legacy file listing. // // All protected by session cookie (argon2id password). The login form is // rendered inline; everything else is served as static HTML from public/admin/ // once authenticated. import { readdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js'; import { verifyPassword } from '../lib/password.js'; import { getMetricsForUI, getStats } from '../lib/stats.js'; const HIDDEN = new Set([ 'node_modules', 'package.json', 'package-lock.json', 'config.js', 'server.js', 'lib', 'routes', 'cron', 'tools', 'test', '.git', '.claude', '.specstory', 'biome.json', 'public', '.env', '.env.example', '.gitignore', 'README.md', '.cron.lock', ]); function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function loginPage(error = '') { return ` Admin · ${esc(TITLE)}

Admin · ${esc(TITLE)}

${esc(error)}
`; } async function renderFilesList() { const names = await readdir(ROOT); const entries = []; for (const name of names) { if (HIDDEN.has(name)) continue; if (name.startsWith('.')) continue; let st; try { st = await stat(join(ROOT, name)); } catch { continue; } entries.push({ name, isDir: st.isDirectory(), size: st.isFile() ? st.size : 0, mtime: st.mtimeMs, }); } entries.sort((a, b) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); return entries; } export default async function adminRoutes(fastify) { // ---- Login / logout / dashboard shell ---- fastify.get('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); if (req.query?.logout != null) { req.session.delete(); return reply.redirect('/admin'); } if (!req.session.get('auth_ok')) return loginPage(); // Serve the dashboard SPA return reply.send(await readFile(join(ROOT, 'public', 'admin.html'))); }); fastify.post('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); const ok = await verifyPassword(ADMIN_PASSWORD_HASH, req.body?.password ?? ''); if (ok) { req.session.set('auth_ok', true); return reply.redirect('/admin'); } return loginPage('Mot de passe incorrect.'); }); // ---- JSON APIs (auth required) ---- function requireAuth(req, reply) { if (!req.session.get('auth_ok')) { reply.code(401).send({ error: 'auth required' }); return false; } return true; } fastify.get('/admin/api/stats', async (req, reply) => { if (!requireAuth(req, reply)) return; return getStats(); }); fastify.get('/admin/api/metrics', async (req, reply) => { if (!requireAuth(req, reply)) return; return getMetricsForUI(); }); fastify.get('/admin/api/files', async (req, reply) => { if (!requireAuth(req, reply)) return; return { entries: await renderFilesList() }; }); }