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
|
|
|
// Protected file listing (was the public root in the PHP version).
|
|
|
|
|
// Now mounted at /admin so the new public UI can live at /.
|
2026-04-23 08:37:48 +02:00
|
|
|
|
|
|
|
|
import { readdir, stat } from 'node:fs/promises';
|
|
|
|
|
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-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 formatBytes(bytes) {
|
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
|
|
|
let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
|
|
|
|
if (i >= units.length) i = units.length - 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
|
|
|
return `${bytes / 1024 ** i || 0} ${units[i]}`;
|
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
|
|
|
function pad(n) {
|
|
|
|
|
return n < 10 ? `0${n}` : String(n);
|
|
|
|
|
}
|
2026-04-23 08:37:48 +02:00
|
|
|
function fmtDate(ms) {
|
|
|
|
|
const d = new Date(ms);
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loginPage(error = '') {
|
|
|
|
|
return `<!doctype html>
|
|
|
|
|
<html lang="fr">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<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-23 08:37:48 +02:00
|
|
|
<style>
|
|
|
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b1220; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
|
|
|
|
|
.card { background:#111827; 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:#60a5fa; }
|
|
|
|
|
.btn { margin-top:12px; width:100%; padding:10px 12px; border:0; border-radius:8px; background:#2563eb; color:white; font-weight:600; cursor:pointer; }
|
|
|
|
|
.btn:hover { filter:brightness(1.05); }
|
|
|
|
|
.err { color:#fca5a5; font-size:14px; margin-top:10px; min-height:18px; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<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>
|
|
|
|
|
<button class="btn" type="submit">Entrer</button>
|
|
|
|
|
<div class="err">${esc(error)}</div>
|
|
|
|
|
</form>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listingPage() {
|
|
|
|
|
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());
|
|
|
|
|
});
|
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 rows = entries
|
|
|
|
|
.map((e) => {
|
|
|
|
|
const href = encodeURIComponent(e.name);
|
|
|
|
|
return `<tr>
|
|
|
|
|
<td class="name"><a href="/admin/files/${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
|
2026-04-23 08:37:48 +02:00
|
|
|
<td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td>
|
|
|
|
|
<td>${esc(fmtDate(e.mtime))}</td>
|
|
|
|
|
</tr>`;
|
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
|
|
|
})
|
|
|
|
|
.join('');
|
2026-04-23 08:37:48 +02:00
|
|
|
return `<!doctype html>
|
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
|
|
|
<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
<title>Admin · ${esc(TITLE)}</title>
|
2026-04-23 08:37:48 +02:00
|
|
|
<style>
|
|
|
|
|
body{ font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#0b1220; color:#e5e7eb; margin:0; }
|
|
|
|
|
header{ display:flex; gap:12px; align-items:center; justify-content:space-between; padding:16px 20px; background:#0f172a; position:sticky; top:0; }
|
|
|
|
|
h1{ font-size:18px; margin:0; color:#f9fafb; }
|
|
|
|
|
a.logout{ color:#93c5fd; text-decoration:none; font-size:14px; }
|
|
|
|
|
.wrap{ max-width:1100px; margin:20px auto; padding:0 16px; }
|
|
|
|
|
table{ width:100%; border-collapse:collapse; background:#111827; border-radius:12px; overflow:hidden; }
|
|
|
|
|
th,td{ padding:12px 14px; border-bottom:1px solid #1f2937; text-align:left; font-size:14px; }
|
|
|
|
|
th{ background:#0f172a; color:#cbd5e1; font-weight:600; }
|
|
|
|
|
tr:hover td{ background:#0b1324; }
|
|
|
|
|
.name a{ color:#93c5fd; text-decoration:none; }
|
|
|
|
|
.name a:hover{ text-decoration:underline; }
|
|
|
|
|
.badge{ font-size:11px; padding:2px 8px; border-radius:999px; background:#1f2937; color:#cbd5e1; margin-left:8px; }
|
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
|
|
|
</style></head>
|
|
|
|
|
<body><header><h1>Admin · ${esc(TITLE)}</h1><a class="logout" href="/admin?logout=1">Se déconnecter</a></header>
|
|
|
|
|
<div class="wrap"><table><thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
|
|
|
</body></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
|
|
|
export default async function adminRoutes(fastify) {
|
|
|
|
|
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-23 08:37:48 +02:00
|
|
|
return listingPage();
|
|
|
|
|
});
|
|
|
|
|
|
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');
|
|
|
|
|
const submitted = 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
|
|
|
const ok = await verifyPassword(ADMIN_PASSWORD_HASH, submitted);
|
|
|
|
|
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.');
|
|
|
|
|
});
|
|
|
|
|
}
|