2026-04-24 08:07:14 +02:00
|
|
|
// 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.
|
2026-04-23 08:37:48 +02:00
|
|
|
|
2026-04-24 08:07:14 +02:00
|
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
2026-04-23 08:37:48 +02:00
|
|
|
import { join } from 'node:path';
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js';
|
|
|
|
|
import { verifyPassword } from '../lib/password.js';
|
2026-04-24 08:48:29 +02:00
|
|
|
import { getDiskUsage, getMetricsForUI, getStats } from '../lib/stats.js';
|
2026-04-23 08:37:48 +02:00
|
|
|
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
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',
|
|
|
|
|
]);
|
2026-04-23 08:37:48 +02:00
|
|
|
|
|
|
|
|
function esc(s) {
|
|
|
|
|
if (s == null) return '';
|
|
|
|
|
return String(s)
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loginPage(error = '') {
|
|
|
|
|
return `<!doctype html>
|
2026-04-24 08:07:14 +02:00
|
|
|
<html lang="fr"><head><meta charset="utf-8">
|
2026-04-23 08:37:48 +02:00
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
<title>Admin · ${esc(TITLE)}</title>
|
2026-04-24 08:07:14 +02:00
|
|
|
<link rel="stylesheet" href="/style.css">
|
2026-04-23 08:37:48 +02:00
|
|
|
<style>
|
2026-04-24 08:07:14 +02:00
|
|
|
body{display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
|
|
|
.card{background:var(--bg-3);padding:24px;border-radius:12px;width:100%;max-width:360px;box-shadow:0 10px 30px rgba(0,0,0,.35)}
|
|
|
|
|
h1{font-size:18px;margin:0 0 16px;color:#f9fafb}
|
|
|
|
|
label{display:block;font-size:14px;margin-bottom:8px;color:#cbd5e1}
|
|
|
|
|
input[type=password]{width:100%;padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;color:#e5e7eb;outline:none}
|
|
|
|
|
input[type=password]:focus{border-color:var(--accent-2)}
|
|
|
|
|
.btn-login{margin-top:12px;width:100%;padding:10px 12px;border:0;border-radius:8px;background:#2563eb;color:white;font-weight:600;cursor:pointer}
|
|
|
|
|
.err{color:var(--danger);font-size:14px;margin-top:10px;min-height:18px}
|
|
|
|
|
</style></head>
|
2026-04-23 08:37:48 +02:00
|
|
|
<body>
|
|
|
|
|
<form class="card" method="post" autocomplete="off">
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
<h1>Admin · ${esc(TITLE)}</h1>
|
2026-04-23 08:37:48 +02:00
|
|
|
<label for="pw">Mot de passe</label>
|
|
|
|
|
<input id="pw" name="password" type="password" required autofocus>
|
2026-04-24 08:07:14 +02:00
|
|
|
<button class="btn-login" type="submit">Entrer</button>
|
2026-04-23 08:37:48 +02:00
|
|
|
<div class="err">${esc(error)}</div>
|
|
|
|
|
</form>
|
2026-04-24 08:07:14 +02:00
|
|
|
</body></html>`;
|
2026-04-23 08:37:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:07:14 +02:00
|
|
|
async function renderFilesList() {
|
2026-04-23 08:37:48 +02:00
|
|
|
const names = await readdir(ROOT);
|
|
|
|
|
const entries = [];
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
if (HIDDEN.has(name)) continue;
|
|
|
|
|
if (name.startsWith('.')) continue;
|
|
|
|
|
let st;
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
try {
|
|
|
|
|
st = await stat(join(ROOT, name));
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-23 08:37:48 +02:00
|
|
|
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());
|
|
|
|
|
});
|
2026-04-24 08:07:14 +02:00
|
|
|
return entries;
|
2026-04-23 08:37:48 +02:00
|
|
|
}
|
|
|
|
|
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
export default async function adminRoutes(fastify) {
|
2026-04-24 08:07:14 +02:00
|
|
|
// ---- Login / logout / dashboard shell ----
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
fastify.get('/admin', async (req, reply) => {
|
2026-04-23 08:37:48 +02:00
|
|
|
reply.header('Content-Type', 'text/html; charset=utf-8');
|
|
|
|
|
if (req.query?.logout != null) {
|
|
|
|
|
req.session.delete();
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
return reply.redirect('/admin');
|
2026-04-23 08:37:48 +02:00
|
|
|
}
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
if (!req.session.get('auth_ok')) return loginPage();
|
2026-04-24 08:07:14 +02:00
|
|
|
// Serve the dashboard SPA
|
|
|
|
|
return reply.send(await readFile(join(ROOT, 'public', 'admin.html')));
|
2026-04-23 08:37:48 +02:00
|
|
|
});
|
|
|
|
|
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
fastify.post('/admin', async (req, reply) => {
|
2026-04-23 08:37:48 +02:00
|
|
|
reply.header('Content-Type', 'text/html; charset=utf-8');
|
2026-04-24 08:07:14 +02:00
|
|
|
const ok = await verifyPassword(ADMIN_PASSWORD_HASH, req.body?.password ?? '');
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
if (ok) {
|
2026-04-23 08:37:48 +02:00
|
|
|
req.session.set('auth_ok', true);
|
Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
2026-04-24 07:35:10 +02:00
|
|
|
return reply.redirect('/admin');
|
2026-04-23 08:37:48 +02:00
|
|
|
}
|
|
|
|
|
return loginPage('Mot de passe incorrect.');
|
|
|
|
|
});
|
2026-04-24 08:07:14 +02:00
|
|
|
|
|
|
|
|
// ---- 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 08:48:29 +02:00
|
|
|
fastify.get('/admin/api/disk', async (req, reply) => {
|
|
|
|
|
if (!requireAuth(req, reply)) return;
|
|
|
|
|
return getDiskUsage();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 08:07:14 +02:00
|
|
|
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() };
|
|
|
|
|
});
|
2026-04-23 08:37:48 +02:00
|
|
|
}
|