feat(admin): dashboard admin complet (auth forte, API, monitoring)

Nouveau service apps/admin (admin.metalfrom.eu / admin.dev.metalfrom.eu) :
- Frontend statique vanilla JS/CSS reprenant le design system du site
  (login, dashboard stats, table bands éditable, queue d'enrichissement,
  historique crawl_run, logs live, checkpoints, journal d'audit)
- nginx reverse-proxy /admin/api/* et /admin/auth/* vers le service api
  interne (same-origin côté navigateur, pas de CORS cross-site nécessaire
  pour le cookie de session)

apps/api :
- Nouvelle auth dédiée au dashboard, séparée du token BM_IMPORT_TOKEN
  existant : login bcrypt + session JWT en cookie httpOnly/secure/
  sameSite=strict, rate-limit + lockout après 5 échecs/15min, seeding
  du compte admin via env vars (jamais de mot de passe en clair en DB
  ou en git)
- Routes /admin/api/* : stats, queue (breakdown priorité identique au
  crawler Python), bands (recherche/tri/pagination/édition + audit log),
  crawl-runs, crawl-checkpoints, logs, audit-log
- trustProxy activé (Traefik + nginx en amont)

apps/crawler :
- log_event() écrit dans la nouvelle table crawl_log (run start/finish/
  erreurs) pour que le dashboard affiche les logs sans exposer le socket
  Docker (choix délibéré : pas de docker.sock monté, accès DB only)

migration 006_admin_dashboard.sql : admin_users, admin_login_attempts,
admin_audit_log, crawl_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-06-30 22:15:13 +02:00
parent 891285a572
commit a7d7bc94de
15 changed files with 2433 additions and 4 deletions

3
apps/admin/Dockerfile Normal file
View file

@ -0,0 +1,3 @@
FROM nginx:alpine
COPY site/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

25
apps/admin/nginx.conf Normal file
View file

@ -0,0 +1,25 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /admin/api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/auth/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

577
apps/admin/site/app.js Normal file
View file

@ -0,0 +1,577 @@
"use strict";
const state = {
username: null,
view: "dashboard",
bands: { page: 1, pageSize: 50, q: "", country: "", status: "", genre: "", enriched: "", sort: "ma_id", dir: "asc", total: 0 },
runs: { page: 1, pageSize: 50, run_type: "", status: "", total: 0 },
logs: { page: 1, pageSize: 100, level: "", autoRefresh: true },
audit: { page: 1, pageSize: 50 },
logsTimer: null,
};
// ------------------------------------------------------------------
// API helper
// ------------------------------------------------------------------
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
if (res.status === 401) {
showLogin();
throw new Error("unauthorized");
}
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) {
throw new Error(body.error || `HTTP ${res.status}`);
}
return body;
}
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
function showLogin() {
document.getElementById("login-screen").classList.remove("hidden");
document.getElementById("app").classList.add("hidden");
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
}
function showApp() {
document.getElementById("login-screen").classList.add("hidden");
document.getElementById("app").classList.remove("hidden");
document.getElementById("whoami").textContent = state.username || "";
}
async function checkSession() {
try {
const r = await api("/admin/auth/me");
state.username = r.username;
showApp();
router();
} catch {
showLogin();
}
}
document.getElementById("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("login-username").value.trim();
const password = document.getElementById("login-password").value;
const errEl = document.getElementById("login-error");
errEl.textContent = "";
try {
const res = await fetch("/admin/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) {
errEl.textContent = body.error || "Échec de connexion";
return;
}
state.username = body.username;
document.getElementById("login-password").value = "";
showApp();
router();
} catch {
errEl.textContent = "Erreur réseau";
}
});
document.getElementById("logout-btn").addEventListener("click", async () => {
try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {}
state.username = null;
showLogin();
});
// ------------------------------------------------------------------
// Router
// ------------------------------------------------------------------
const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "checkpoints", "audit"];
function router() {
const hash = (location.hash || "#/dashboard").replace("#/", "");
const view = VIEWS.includes(hash) ? hash : "dashboard";
state.view = view;
document.querySelectorAll(".nav a").forEach((a) => {
a.classList.toggle("active", a.dataset.view === view);
});
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
const renderers = {
dashboard: renderDashboard,
bands: renderBands,
queue: renderQueue,
runs: renderRuns,
logs: renderLogs,
checkpoints: renderCheckpoints,
audit: renderAudit,
};
renderers[view]();
}
window.addEventListener("hashchange", router);
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtDate(s) {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
}
const content = () => document.getElementById("content");
// ------------------------------------------------------------------
// Dashboard
// ------------------------------------------------------------------
async function renderDashboard() {
content().innerHTML = `<div class="loading">Chargement…</div>`;
try {
const r = await api("/admin/api/stats");
const t = r.totals;
const maxStatus = Math.max(1, ...r.by_status.map((x) => x.total));
const maxCountry = Math.max(1, ...r.by_country.map((x) => x.total));
const maxGenre = Math.max(1, ...r.by_genre.map((x) => x.total));
content().innerHTML = `
<div class="grid grid-stats">
${statCard("Total bands", t.total)}
${statCard("Enrichis", t.enriched, "ok")}
${statCard("Non enrichis", t.not_enriched, "warn")}
${statCard("Géocodés", t.geocoded, "ok")}
${statCard("Sans localisation", t.not_geocoded, "warn")}
${statCard("Jamais crawlés (nouveau système)", t.total - t.crawled_by_current_system, "warn")}
${statCard("Jamais enrichis (band_page absent)", t.never_enriched, "err")}
${statCard("Stale (>30j)", t.stale, "warn")}
</div>
<div class="grid grid-2">
<div class="card">
<h2>Par statut</h2>
${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()}
</div>
<div class="card">
<h2>Par pays (top 20)</h2>
${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()}
</div>
<div class="card">
<h2>Par genre (top 20)</h2>
${r.by_genre.map((x) => barRow(x.genre, x.total, maxGenre)).join("") || emptyRow()}
</div>
</div>
`;
} catch (e) {
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
function statCard(label, value, cls = "") {
return `<div class="card stat-card"><div class="label">${esc(label)}</div><div class="value ${cls}">${Number(value).toLocaleString("fr-FR")}</div></div>`;
}
function barRow(label, count, max) {
const pct = Math.max(2, Math.round((count / max) * 100));
return `<div class="bar-row"><div class="bar-label" title="${esc(label)}">${esc(label)}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div><div class="bar-count">${count}</div></div>`;
}
function emptyRow() { return `<div class="empty">Aucune donnée</div>`; }
// ------------------------------------------------------------------
// Queue
// ------------------------------------------------------------------
async function renderQueue() {
content().innerHTML = `<div class="loading">Chargement…</div>`;
try {
const r = await api("/admin/api/queue");
const b = r.breakdown;
content().innerHTML = `
<div class="grid grid-stats">
${statCard("Nouveaux (priorité 1)", b.new_bands, "err")}
${statCard("Modifiés depuis enrich (priorité 2)", b.modified_since_enrich, "warn")}
${statCard("Legacy à ré-enrichir (priorité 3)", b.legacy_pending, "warn")}
${statCard("Stale >30j (priorité 4)", b.stale, "ok")}
</div>
<div class="card">
<h2>Derniers runs d'enrichissement</h2>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>Statut</th><th>Début</th><th>Fin</th><th>Vus</th><th>Enrichis</th><th>Erreur</th></tr></thead>
<tbody>
${r.recent_enrich_runs.map((x) => `
<tr>
<td>${x.id}</td>
<td>${statusBadge(x.status)}</td>
<td>${fmtDate(x.started_at)}</td>
<td>${fmtDate(x.finished_at)}</td>
<td>${x.bands_seen}</td>
<td>${x.bands_enriched}</td>
<td>${esc(x.error || "")}</td>
</tr>
`).join("") || `<tr><td colspan="7" class="empty">Aucun run</td></tr>`}
</tbody>
</table></div>
</div>
`;
} catch (e) {
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
function statusBadge(status) {
const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted";
return `<span class="badge ${cls}">${esc(status)}</span>`;
}
// ------------------------------------------------------------------
// Bands
// ------------------------------------------------------------------
async function renderBands() {
const s = state.bands;
content().innerHTML = `
<div class="toolbar">
<input type="text" id="b-q" placeholder="Recherche (nom, genre, lieu)…" value="${esc(s.q)}" style="min-width:240px">
<input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" style="width:120px">
<input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" style="width:140px">
<input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" style="width:160px">
<select id="b-enriched">
<option value="">Enrichi: tous</option>
<option value="true" ${s.enriched === "true" ? "selected" : ""}>Enrichi</option>
<option value="false" ${s.enriched === "false" ? "selected" : ""}>Non enrichi</option>
</select>
<button class="btn" id="b-apply">Filtrer</button>
</div>
<div id="b-table" class="table-wrap"><div class="loading">Chargement</div></div>
<div class="pager" id="b-pager"></div>
`;
document.getElementById("b-apply").addEventListener("click", () => {
s.q = document.getElementById("b-q").value.trim();
s.country = document.getElementById("b-country").value.trim();
s.status = document.getElementById("b-status").value.trim();
s.genre = document.getElementById("b-genre").value.trim();
s.enriched = document.getElementById("b-enriched").value;
s.page = 1;
loadBands();
});
await loadBands();
}
const BAND_COLUMNS = [
{ key: "ma_id", label: "MA ID" },
{ key: "name", label: "Nom" },
{ key: "country", label: "Pays" },
{ key: "status", label: "Statut" },
{ key: "genre", label: "Genre" },
{ key: "formed_year", label: "Année" },
{ key: "enriched", label: "Enrichi" },
{ key: "crawled_at", label: "Crawlé le" },
{ key: "updated_at", label: "Modifié le" },
];
async function loadBands() {
const s = state.bands;
const params = new URLSearchParams({
page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir,
});
if (s.q) params.set("q", s.q);
if (s.country) params.set("country", s.country);
if (s.status) params.set("status", s.status);
if (s.genre) params.set("genre", s.genre);
if (s.enriched) params.set("enriched", s.enriched);
try {
const r = await api(`/admin/api/bands?${params}`);
s.total = r.total;
const tableEl = document.getElementById("b-table");
tableEl.innerHTML = `
<table>
<thead><tr>
${BAND_COLUMNS.map((c) => `<th data-sort="${c.key}">${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`).join("")}
</tr></thead>
<tbody>
${r.items.map((b) => `
<tr class="clickable" data-ma-id="${b.ma_id}">
<td>${b.ma_id}</td>
<td>${esc(b.name)}</td>
<td>${esc(b.country || "")}</td>
<td>${esc(b.status || "")}</td>
<td>${esc(b.genre || "")}</td>
<td>${b.formed_year || ""}</td>
<td>${b.enriched ? '<span class="badge ok">oui</span>' : '<span class="badge warn">non</span>'}</td>
<td>${fmtDate(b.crawled_at)}</td>
<td>${fmtDate(b.updated_at)}</td>
</tr>
`).join("") || `<tr><td colspan="${BAND_COLUMNS.length}" class="empty">Aucun résultat</td></tr>`}
</tbody>
</table>
`;
tableEl.querySelectorAll("th[data-sort]").forEach((th) => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
if (s.sort === key) s.dir = s.dir === "asc" ? "desc" : "asc";
else { s.sort = key; s.dir = "asc"; }
loadBands();
});
});
tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => {
tr.addEventListener("click", () => openBandModal(Number(tr.dataset.maId)));
});
const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
document.getElementById("b-pager").innerHTML = `
<button class="btn btn-mini" id="b-prev" ${s.page <= 1 ? "disabled" : ""}> Préc.</button>
<span>Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")} bands)</span>
<button class="btn btn-mini" id="b-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. </button>
`;
document.getElementById("b-prev")?.addEventListener("click", () => { s.page--; loadBands(); });
document.getElementById("b-next")?.addEventListener("click", () => { s.page++; loadBands(); });
} catch (e) {
document.getElementById("b-table").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
async function openBandModal(maId) {
let band;
try {
const r = await api(`/admin/api/bands/${maId}`);
band = r.item;
} catch (e) {
alert(`Erreur : ${e.message}`);
return;
}
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<span class="modal-close">&times;</span>
<h3>${esc(band.name)} <span style="color:var(--muted);font-weight:400">#${band.ma_id}</span></h3>
<label>Nom<input type="text" id="m-name" value="${esc(band.name || "")}"></label>
<label>Pays<input type="text" id="m-country" value="${esc(band.country || "")}"></label>
<label>Statut<input type="text" id="m-status" value="${esc(band.status || "")}"></label>
<label>Genre<input type="text" id="m-genre" value="${esc(band.genre || "")}"></label>
<label>Année de formation<input type="number" id="m-formed_year" value="${band.formed_year ?? ""}"></label>
<label>Localisation<input type="text" id="m-location_text" value="${esc(band.location_text || "")}"></label>
<label>Thèmes<input type="text" id="m-themes" value="${esc(band.themes || "")}"></label>
<label>Latitude<input type="number" step="any" id="m-lat" value="${band.lat ?? ""}"></label>
<label>Longitude<input type="number" step="any" id="m-lon" value="${band.lon ?? ""}"></label>
<div class="modal-error" id="m-error"></div>
<div class="modal-actions">
<button class="btn btn-mini" id="m-cancel">Annuler</button>
<button class="btn" id="m-save">Enregistrer</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
backdrop.querySelector("#m-cancel").addEventListener("click", close);
backdrop.querySelector("#m-save").addEventListener("click", async () => {
const errEl = backdrop.querySelector("#m-error");
errEl.textContent = "";
const payload = {
name: backdrop.querySelector("#m-name").value.trim() || null,
country: backdrop.querySelector("#m-country").value.trim() || null,
status: backdrop.querySelector("#m-status").value.trim() || null,
genre: backdrop.querySelector("#m-genre").value.trim() || null,
location_text: backdrop.querySelector("#m-location_text").value.trim() || null,
themes: backdrop.querySelector("#m-themes").value.trim() || null,
formed_year: backdrop.querySelector("#m-formed_year").value ? Number(backdrop.querySelector("#m-formed_year").value) : null,
lat: backdrop.querySelector("#m-lat").value ? Number(backdrop.querySelector("#m-lat").value) : null,
lon: backdrop.querySelector("#m-lon").value ? Number(backdrop.querySelector("#m-lon").value) : null,
};
try {
await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) });
close();
loadBands();
} catch (e) {
errEl.textContent = e.message;
}
});
}
// ------------------------------------------------------------------
// Crawl runs
// ------------------------------------------------------------------
async function renderRuns() {
const s = state.runs;
content().innerHTML = `
<div class="toolbar">
<select id="r-type">
<option value="">Type: tous</option>
<option value="full_europe">full_europe</option>
<option value="incremental_created">incremental_created</option>
<option value="incremental_modified">incremental_modified</option>
<option value="enrich">enrich</option>
</select>
<select id="r-status">
<option value="">Statut: tous</option>
<option value="running">running</option>
<option value="done">done</option>
<option value="error">error</option>
</select>
<button class="btn" id="r-apply">Filtrer</button>
</div>
<div id="r-table" class="table-wrap"><div class="loading">Chargement</div></div>
<div class="pager" id="r-pager"></div>
`;
document.getElementById("r-apply").addEventListener("click", () => {
s.run_type = document.getElementById("r-type").value;
s.status = document.getElementById("r-status").value;
s.page = 1;
loadRuns();
});
await loadRuns();
}
async function loadRuns() {
const s = state.runs;
const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize });
if (s.run_type) params.set("run_type", s.run_type);
if (s.status) params.set("status", s.status);
try {
const r = await api(`/admin/api/crawl-runs?${params}`);
s.total = r.total;
document.getElementById("r-table").innerHTML = `
<table>
<thead><tr><th>ID</th><th>Type</th><th>Pays</th><th>Statut</th><th>Début</th><th>Fin</th><th>Vus</th><th>Nouveaux</th><th>MAJ</th><th>Enrichis</th><th>Erreur</th></tr></thead>
<tbody>
${r.items.map((x) => `
<tr>
<td>${x.id}</td>
<td>${esc(x.run_type)}</td>
<td>${esc((x.countries || []).join(", "))}</td>
<td>${statusBadge(x.status)}</td>
<td>${fmtDate(x.started_at)}</td>
<td>${fmtDate(x.finished_at)}</td>
<td>${x.bands_seen}</td>
<td>${x.bands_new}</td>
<td>${x.bands_updated}</td>
<td>${x.bands_enriched}</td>
<td>${esc(x.error || "")}</td>
</tr>
`).join("") || `<tr><td colspan="11" class="empty">Aucun run</td></tr>`}
</tbody>
</table>
`;
const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
document.getElementById("r-pager").innerHTML = `
<button class="btn btn-mini" id="r-prev" ${s.page <= 1 ? "disabled" : ""}> Préc.</button>
<span>Page ${s.page} / ${totalPages} (${s.total} runs)</span>
<button class="btn btn-mini" id="r-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. </button>
`;
document.getElementById("r-prev")?.addEventListener("click", () => { s.page--; loadRuns(); });
document.getElementById("r-next")?.addEventListener("click", () => { s.page++; loadRuns(); });
} catch (e) {
document.getElementById("r-table").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
// ------------------------------------------------------------------
// Logs
// ------------------------------------------------------------------
async function renderLogs() {
const s = state.logs;
content().innerHTML = `
<div class="toolbar">
<select id="l-level">
<option value="">Niveau: tous</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button class="btn" id="l-apply">Filtrer</button>
<label style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="l-auto" ${s.autoRefresh ? "checked" : ""}> Auto-refresh (10s)
</label>
</div>
<div id="l-list" class="card"><div class="loading">Chargement</div></div>
`;
document.getElementById("l-apply").addEventListener("click", () => {
s.level = document.getElementById("l-level").value;
loadLogs();
});
document.getElementById("l-auto").addEventListener("change", (e) => {
s.autoRefresh = e.target.checked;
setupLogsTimer();
});
await loadLogs();
setupLogsTimer();
}
function setupLogsTimer() {
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
if (state.logs.autoRefresh) {
state.logsTimer = setInterval(loadLogs, 10000);
}
}
async function loadLogs() {
const s = state.logs;
const params = new URLSearchParams({ page: 1, pageSize: s.pageSize });
if (s.level) params.set("level", s.level);
try {
const r = await api(`/admin/api/logs?${params}`);
document.getElementById("l-list").innerHTML = r.items.map((x) => `
<div class="log-line">
<span class="ts">${fmtDate(x.created_at)}</span>
<span class="lvl ${esc(x.level)}">${esc(x.level)}</span>
<span>${esc(x.message)}${x.ma_id ? ` <span style="color:var(--muted)">(ma_id=${x.ma_id})</span>` : ""}</span>
</div>
`).join("") || `<div class="empty">Aucun log</div>`;
} catch (e) {
document.getElementById("l-list").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
// ------------------------------------------------------------------
// Checkpoints
// ------------------------------------------------------------------
async function renderCheckpoints() {
content().innerHTML = `<div class="loading">Chargement…</div>`;
try {
const r = await api("/admin/api/crawl-checkpoints");
content().innerHTML = `
<div class="table-wrap"><table>
<thead><tr><th>Clé</th><th>Valeur</th><th>Mis à jour</th></tr></thead>
<tbody>
${r.items.map((x) => `<tr><td>${esc(x.key)}</td><td>${esc(x.value || "—")}</td><td>${fmtDate(x.updated_at)}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">Aucun checkpoint</td></tr>`}
</tbody>
</table></div>
`;
} catch (e) {
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
// ------------------------------------------------------------------
// Audit log
// ------------------------------------------------------------------
async function renderAudit() {
content().innerHTML = `<div class="loading">Chargement…</div>`;
try {
const r = await api(`/admin/api/audit-log?page=1&pageSize=${state.audit.pageSize}`);
content().innerHTML = `
<div class="table-wrap"><table>
<thead><tr><th>Date</th><th>Admin</th><th>Action</th><th>Table</th><th>ID cible</th></tr></thead>
<tbody>
${r.items.map((x) => `
<tr>
<td>${fmtDate(x.created_at)}</td>
<td>${esc(x.admin_username)}</td>
<td>${esc(x.action)}</td>
<td>${esc(x.target_table)}</td>
<td>${esc(x.target_id)}</td>
</tr>
`).join("") || `<tr><td colspan="5" class="empty">Aucune action</td></tr>`}
</tbody>
</table></div>
`;
} catch (e) {
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
// ------------------------------------------------------------------
// Boot
// ------------------------------------------------------------------
checkSession();

View file

@ -0,0 +1,52 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Admin — metalfrom.eu</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=UnifrakturCook:wght@700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="login-screen" class="login-screen">
<form id="login-form" class="login-card">
<div class="brand">Admin</div>
<p class="login-sub">metalfrom.eu — accès restreint</p>
<label>Utilisateur
<input type="text" id="login-username" autocomplete="username" required>
</label>
<label>Mot de passe
<input type="password" id="login-password" autocomplete="current-password" required>
</label>
<button type="submit" class="btn btn-primary">Se connecter</button>
<div id="login-error" class="login-error"></div>
</form>
</div>
<div id="app" class="app hidden">
<header id="topbar">
<div class="brand">Admin</div>
<nav id="nav" class="nav">
<a href="#/dashboard" data-view="dashboard">Dashboard</a>
<a href="#/bands" data-view="bands">Bands</a>
<a href="#/queue" data-view="queue">Queue</a>
<a href="#/runs" data-view="runs">Crawl runs</a>
<a href="#/logs" data-view="logs">Logs</a>
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>
<a href="#/audit" data-view="audit">Audit</a>
</nav>
<div class="topbar-right">
<span id="whoami" class="whoami"></span>
<button id="logout-btn" class="btn btn-mini">Déconnexion</button>
</div>
</header>
<main id="content" class="content"></main>
</div>
<script src="app.js"></script>
</body>
</html>

293
apps/admin/site/styles.css Normal file
View file

@ -0,0 +1,293 @@
:root {
--bg: #05060a;
--text: #e9eef5;
--muted: #a2b0c2;
--border: rgba(255,255,255,0.08);
--blood: #c61a1a;
--bone: #e7e2da;
--glow: rgba(198,26,26,0.22);
--topbar-height: 64px;
--ok: #3fae5a;
--warn: #d99a2b;
--err: #c61a1a;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(1200px 800px at 18% 12%, rgba(198,26,26,0.10), transparent 56%),
radial-gradient(900px 700px at 90% 20%, rgba(231,226,218,0.04), transparent 60%),
radial-gradient(1400px 900px at 50% 110%, rgba(0,0,0,0.8), transparent 60%);
pointer-events: none;
z-index: 0;
}
.hidden { display: none !important; }
a { color: inherit; }
/* ===== LOGIN ===== */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.login-card {
width: 340px;
background: rgba(8,10,16,0.92);
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px 24px;
box-shadow: 0 20px 80px rgba(0,0,0,0.5);
backdrop-filter: blur(12px);
}
.login-card .brand {
font-family: "UnifrakturCook", serif;
font-size: 32px;
text-align: center;
text-shadow: 0 0 24px var(--glow);
}
.login-sub {
text-align: center;
color: var(--muted);
font-size: 12px;
margin: 4px 0 20px;
}
.login-card label {
display: block;
font-size: 12px;
color: var(--muted);
font-weight: 700;
margin-bottom: 14px;
}
.login-card input {
display: block;
width: 100%;
margin-top: 6px;
padding: 11px 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(4,6,10,0.72);
color: var(--text);
font-size: 14px;
outline: none;
}
.login-card input:focus {
border-color: rgba(198,26,26,0.55);
box-shadow: 0 0 0 3px rgba(198,26,26,0.10);
}
.login-error {
color: var(--err);
font-size: 12px;
margin-top: 12px;
min-height: 14px;
text-align: center;
}
/* ===== APP SHELL ===== */
.app {
position: relative;
z-index: 1;
min-height: 100vh;
}
#topbar {
position: sticky;
top: 0;
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: var(--topbar-height);
background: linear-gradient(90deg, rgba(6,8,14,0.96), rgba(12,14,22,0.92));
border-bottom: 1px solid var(--border);
box-shadow: 0 12px 50px rgba(0,0,0,0.35);
z-index: 10;
}
#topbar .brand {
font-family: "UnifrakturCook", serif;
font-size: 24px;
text-shadow: 0 0 24px var(--glow);
white-space: nowrap;
}
.nav {
display: flex;
gap: 4px;
flex: 1;
overflow-x: auto;
}
.nav a {
padding: 8px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
text-decoration: none;
color: var(--muted);
white-space: nowrap;
transition: background 120ms ease, color 120ms ease;
}
.nav a:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.nav a.active { color: var(--bone); background: rgba(198,26,26,0.16); }
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.whoami { font-size: 12px; color: var(--muted); }
.content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* ===== COMPONENTS ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(4,6,10,0.62);
color: rgba(231,226,218,0.92);
padding: 9px 14px;
border-radius: 12px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.btn:hover { transform: translateY(-1px); border-color: rgba(198,26,26,0.40); background: rgba(198,26,26,0.10); }
.btn-mini { padding: 6px 10px; font-size: 12px; border-radius: 10px; }
.btn-primary { width: 100%; justify-content: center; background: rgba(198,26,26,0.18); border-color: rgba(198,26,26,0.45); }
.btn-primary:hover { background: rgba(198,26,26,0.30); }
.card {
background: rgba(8,10,16,0.85);
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.20);
}
.grid {
display: grid;
gap: 16px;
}
.grid-stats { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-bottom: 20px; }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 700; }
.stat-card .value { font-size: 30px; font-weight: 800; margin-top: 6px; color: var(--bone); }
.stat-card .value.ok { color: var(--ok); }
.stat-card .value.warn { color: var(--warn); }
.stat-card .value.err { color: var(--err); }
h2 { font-size: 16px; margin: 0 0 14px; color: var(--bone); }
.bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; padding: 5px 0; }
.bar-row .bar-label { width: 110px; flex-shrink: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.06); border-radius: 6px; overflow: hidden; }
.bar-row .bar-fill { height: 100%; background: linear-gradient(90deg, var(--blood), rgba(198,26,26,0.6)); }
.bar-row .bar-count { width: 50px; text-align: right; color: var(--text); font-weight: 700; }
/* ===== TOOLBAR / FILTERS ===== */
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; align-items: center; }
.toolbar input, .toolbar select {
padding: 9px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(4,6,10,0.62);
color: var(--text);
font-size: 13px;
outline: none;
}
.toolbar input:focus, .toolbar select:focus { border-color: rgba(198,26,26,0.45); }
/* ===== TABLE ===== */
.table-wrap { overflow-x: auto; border-radius: 14px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
text-align: left;
padding: 10px 12px;
background: rgba(255,255,255,0.04);
color: var(--muted);
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
thead th:hover { color: var(--bone); }
tbody td { padding: 9px 12px; border-top: 1px solid rgba(255,255,255,0.05); white-space: nowrap; }
tbody tr:hover { background: rgba(198,26,26,0.05); }
tbody tr.clickable { cursor: pointer; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
.badge.ok { background: rgba(63,174,90,0.15); color: var(--ok); }
.badge.warn { background: rgba(217,154,43,0.15); color: var(--warn); }
.badge.err { background: rgba(198,26,26,0.15); color: var(--err); }
.badge.muted { background: rgba(162,176,194,0.15); color: var(--muted); }
.pager { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 12px; color: var(--muted); }
/* ===== MODAL ===== */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal {
width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto;
background: rgba(10,12,18,0.97); border: 1px solid var(--border); border-radius: 16px;
padding: 22px; box-shadow: 0 20px 80px rgba(0,0,0,0.5);
}
.modal h3 { margin: 0 0 16px; color: var(--bone); }
.modal label { display: block; font-size: 11px; color: var(--muted); font-weight: 700; margin-bottom: 12px; text-transform: uppercase; }
.modal input, .modal textarea {
display: block; width: 100%; margin-top: 6px; padding: 9px 11px; border-radius: 10px;
border: 1px solid rgba(255,255,255,0.10); background: rgba(4,6,10,0.72); color: var(--text);
font-size: 13px; outline: none; font-family: inherit;
}
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
.modal-error { color: var(--err); font-size: 12px; margin-top: 10px; }
.modal-close { float: right; cursor: pointer; color: var(--muted); font-size: 18px; line-height: 1; }
.log-line { font-family: ui-monospace, Consolas, monospace; font-size: 12px; padding: 4px 0; border-top: 1px solid rgba(255,255,255,0.04); display: flex; gap: 10px; }
.log-line .ts { color: var(--muted); flex-shrink: 0; }
.log-line .lvl { flex-shrink: 0; width: 56px; font-weight: 700; }
.log-line .lvl.info { color: var(--muted); }
.log-line .lvl.warning { color: var(--warn); }
.log-line .lvl.error { color: var(--err); }
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }

View file

@ -0,0 +1,42 @@
-- 006: tables pour le dashboard admin (auth, audit, logs crawler)
CREATE TABLE IF NOT EXISTS admin_users (
id BIGSERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS admin_login_attempts (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
ip TEXT,
success BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_username_time
ON admin_login_attempts (username, created_at DESC);
CREATE TABLE IF NOT EXISTS admin_audit_log (
id BIGSERIAL PRIMARY KEY,
admin_username TEXT NOT NULL,
action TEXT NOT NULL,
target_table TEXT NOT NULL,
target_id TEXT,
before_data JSONB,
after_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log (created_at DESC);
CREATE TABLE IF NOT EXISTS crawl_log (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT REFERENCES crawl_run(id) ON DELETE SET NULL,
level TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
ma_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_crawl_log_created_at ON crawl_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_crawl_log_run_id ON crawl_log (run_id);

852
apps/api/package-lock.json generated Normal file
View file

@ -0,0 +1,852 @@
{
"name": "bm-api",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bm-api",
"version": "0.0.1",
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^9.0.0",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz",
"integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==",
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.1.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^5.7.0"
}
},
"node_modules/@fastify/helmet": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-11.1.1.tgz",
"integrity": "sha512-pjJxjk6SLEimITWadtYIXt6wBMfFC1I6OQyH/jYVCqSAn36sgAIFjeNiibHtifjCd+e25442pObis3Rjtame6A==",
"license": "MIT",
"dependencies": {
"fastify-plugin": "^4.2.1",
"helmet": "^7.0.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.3.0",
"fastq": "^1.17.1"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.1.0",
"ajv": "^8.10.0",
"ajv-formats": "^3.0.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^2.1.0",
"json-schema-ref-resolver": "^1.0.1",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz",
"integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==",
"license": "MIT"
},
"node_modules/fastify": {
"version": "4.29.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^3.5.0",
"@fastify/error": "^3.4.0",
"@fastify/fast-json-stringify-compiler": "^4.3.0",
"abstract-logging": "^2.0.1",
"avvio": "^8.3.0",
"fast-content-type-parse": "^1.1.0",
"fast-json-stringify": "^5.8.0",
"find-my-way": "^8.0.0",
"light-my-request": "^5.11.0",
"pino": "^9.0.0",
"process-warning": "^3.0.0",
"proxy-addr": "^2.0.7",
"rfdc": "^1.3.0",
"secure-json-parse": "^2.7.0",
"semver": "^7.5.4",
"toad-cache": "^3.3.0"
}
},
"node_modules/fastify-plugin": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^3.1.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
"integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^0.7.0",
"process-warning": "^3.0.0",
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pino/node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
"license": "MIT",
"dependencies": {
"ret": "~0.4.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz",
"integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View file

@ -11,6 +11,9 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"@fastify/rate-limit": "^9.0.0", "@fastify/rate-limit": "^9.0.0",
"@fastify/helmet": "^11.1.1", "@fastify/helmet": "^11.1.1",
"pg": "^8.12.0" "@fastify/cookie": "^9.4.0",
"pg": "^8.12.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
} }
} }

98
apps/api/src/adminAuth.js Normal file
View file

@ -0,0 +1,98 @@
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
export const ADMIN_COOKIE_NAME = "admin_session";
const SESSION_TTL_S = 12 * 3600; // 12h
const LOCKOUT_WINDOW_MIN = 15;
const LOCKOUT_MAX_ATTEMPTS = 5;
// Hash bcrypt valide mais sans correspondance, utilisé pour égaliser le temps
// de réponse quand le username n'existe pas (évite l'énumération de comptes).
const DUMMY_HASH = "$2b$12$CwTycUXWue0Thq9StjUM0uJ8vKR1dlT0LYzGsv8ZE8nFI8q9Z5T9.";
function jwtSecret() {
const secret = (process.env.ADMIN_JWT_SECRET || "").trim();
if (!secret || secret.length < 32) {
throw new Error("ADMIN_JWT_SECRET manquant ou trop court (min 32 caractères)");
}
return secret;
}
export async function seedAdminUser(pool) {
const username = (process.env.ADMIN_SEED_USERNAME || "").trim();
const passwordHash = (process.env.ADMIN_SEED_PASSWORD_HASH || "").trim();
if (!username || !passwordHash) return;
await pool.query(
`INSERT INTO admin_users (username, password_hash)
VALUES ($1, $2)
ON CONFLICT (username) DO NOTHING`,
[username, passwordHash]
);
}
export function signAdminSession(username) {
return jwt.sign({ sub: username }, jwtSecret(), { expiresIn: SESSION_TTL_S });
}
export function verifyAdminSession(token) {
try {
const payload = jwt.verify(token, jwtSecret());
return typeof payload.sub === "string" ? payload.sub : null;
} catch {
return null;
}
}
export async function isLockedOut(pool, username, ip) {
const r = await pool.query(
`SELECT count(*)::int AS n
FROM admin_login_attempts
WHERE success = false
AND created_at > now() - make_interval(mins => $1)
AND (username = $2 OR ip = $3)`,
[LOCKOUT_WINDOW_MIN, username, ip]
);
return r.rows[0].n >= LOCKOUT_MAX_ATTEMPTS;
}
export async function recordLoginAttempt(pool, username, ip, success) {
await pool.query(
`INSERT INTO admin_login_attempts (username, ip, success) VALUES ($1, $2, $3)`,
[username, ip, success]
);
}
export async function verifyPassword(pool, username, password) {
const r = await pool.query(
`SELECT password_hash FROM admin_users WHERE username = $1`,
[username]
);
const hash = r.rows.length ? r.rows[0].password_hash : DUMMY_HASH;
const valid = await bcrypt.compare(password, hash);
return valid && r.rows.length > 0;
}
export async function markLoginSuccess(pool, username) {
await pool.query(
`UPDATE admin_users SET last_login_at = now() WHERE username = $1`,
[username]
);
}
export function requireAdminSession(req, reply) {
const token = req.cookies?.[ADMIN_COOKIE_NAME];
const username = token ? verifyAdminSession(token) : null;
if (!username) {
reply.code(401).send({ ok: false, error: "unauthorized" });
return null;
}
return username;
}
export async function writeAuditLog(pool, adminUsername, action, targetTable, targetId, before, after) {
await pool.query(
`INSERT INTO admin_audit_log (admin_username, action, target_table, target_id, before_data, after_data)
VALUES ($1, $2, $3, $4, $5, $6)`,
[adminUsername, action, targetTable, String(targetId), before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null]
);
}

345
apps/api/src/adminRoutes.js Normal file
View file

@ -0,0 +1,345 @@
import { requireAdminSession, writeAuditLog } from "./adminAuth.js";
const BAND_SORT_COLUMNS = new Set([
"ma_id", "name", "country", "status", "genre",
"formed_year", "enriched", "crawled_at", "updated_at", "first_seen_at",
]);
const BAND_EDITABLE_FIELDS = [
"name", "country", "status", "genre", "formed_year",
"themes", "location_text", "lat", "lon",
];
function pagination(query) {
const page = Math.max(1, Number(query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(query.pageSize) || 50));
return { page, pageSize, offset: (page - 1) * pageSize };
}
export default async function adminRoutes(fastify, opts) {
const { pool } = opts;
fastify.addHook("preHandler", async (req, reply) => {
const username = requireAdminSession(req, reply);
if (!username) return reply; // already sent 401
req.adminUsername = username;
});
// ------------------------------------------------------------------
// Stats globales
// ------------------------------------------------------------------
fastify.get("/admin/api/stats", async (req, reply) => {
try {
const [totals, byStatus, byCountry, byGenre] = await Promise.all([
pool.query(`
SELECT
count(*)::int AS total,
count(*) FILTER (WHERE enriched = true)::int AS enriched,
count(*) FILTER (WHERE enriched = false)::int AS not_enriched,
count(*) FILTER (WHERE geom IS NOT NULL)::int AS geocoded,
count(*) FILTER (WHERE geom IS NULL)::int AS not_geocoded,
count(*) FILTER (WHERE crawled_at IS NOT NULL)::int AS crawled_by_current_system,
count(*) FILTER (WHERE data->'band_page' IS NULL)::int AS never_enriched,
count(*) FILTER (WHERE crawled_at < now() - interval '30 days')::int AS stale
FROM bands
`),
pool.query(`
SELECT COALESCE(NULLIF(trim(status), ''), 'Unknown') AS status, count(*)::int AS total
FROM bands GROUP BY 1 ORDER BY total DESC LIMIT 20
`),
pool.query(`
SELECT COALESCE(country, '??') AS country, count(*)::int AS total
FROM bands GROUP BY 1 ORDER BY total DESC LIMIT 20
`),
pool.query(`
SELECT genre, count(*)::int AS total
FROM bands WHERE genre IS NOT NULL AND genre != ''
GROUP BY 1 ORDER BY total DESC LIMIT 20
`),
]);
return {
ok: true,
totals: totals.rows[0],
by_status: byStatus.rows,
by_country: byCountry.rows,
by_genre: byGenre.rows,
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur stats" });
}
});
// ------------------------------------------------------------------
// Queue d'enrichissement (même logique que le crawler Python)
// ------------------------------------------------------------------
fastify.get("/admin/api/queue", async (req, reply) => {
try {
const r = await pool.query(`
SELECT
count(*) FILTER (WHERE data->'band_page' IS NULL)::int AS new_bands,
count(*) FILTER (
WHERE crawled_at IS NOT NULL AND updated_at > crawled_at + interval '1 minute'
)::int AS modified_since_enrich,
count(*) FILTER (
WHERE crawled_at IS NULL AND data->'band_page' IS NOT NULL
)::int AS legacy_pending,
count(*) FILTER (
WHERE crawled_at IS NOT NULL AND crawled_at < now() - interval '30 days'
)::int AS stale
FROM bands
WHERE data->>'url' IS NOT NULL
`);
const recentRuns = await pool.query(`
SELECT id, run_type, status, started_at, finished_at,
bands_seen, bands_new, bands_updated, bands_enriched, error
FROM crawl_run
WHERE run_type = 'enrich'
ORDER BY started_at DESC
LIMIT 5
`);
return { ok: true, breakdown: r.rows[0], recent_enrich_runs: recentRuns.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur queue" });
}
});
// ------------------------------------------------------------------
// Bands - liste paginée / recherche / filtres
// ------------------------------------------------------------------
fastify.get("/admin/api/bands", async (req, reply) => {
try {
const { q, country, status, genre, enriched, sort, dir } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
let i = 1;
if (q) {
const query = String(q).trim().slice(0, 100);
if (query.length >= 2) {
where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i})`);
vals.push(`%${query}%`);
i++;
}
}
if (country) {
where.push(`country = $${i}`);
vals.push(String(country).toUpperCase());
i++;
}
if (status) {
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = $${i}`);
vals.push(String(status));
i++;
}
if (genre) {
where.push(`genre ILIKE $${i}`);
vals.push(`%${String(genre)}%`);
i++;
}
if (enriched === "true" || enriched === "1") where.push(`enriched = true`);
if (enriched === "false" || enriched === "0") where.push(`enriched = false`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const sortCol = BAND_SORT_COLUMNS.has(sort) ? sort : "ma_id";
const sortDir = dir === "desc" ? "DESC" : "ASC";
const countSql = `SELECT count(*)::int AS total FROM bands ${whereSql}`;
const dataSql = `
SELECT ma_id, name, country, status, genre, location_text, formed_year,
themes, enriched, lat, lon, crawled_at, updated_at, first_seen_at
FROM bands
${whereSql}
ORDER BY ${sortCol} ${sortDir} NULLS LAST
LIMIT $${i} OFFSET $${i + 1}
`;
vals.push(pageSize, offset);
const [countRes, dataRes] = await Promise.all([
pool.query(countSql, vals.slice(0, i - 1)),
pool.query(dataSql, vals),
]);
return {
ok: true,
items: dataRes.rows,
total: countRes.rows[0].total,
page,
pageSize,
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur recherche bands" });
}
});
fastify.get("/admin/api/bands/:ma_id", async (req, reply) => {
try {
const id = Number(req.params.ma_id);
if (!Number.isFinite(id) || id < 0) {
return reply.code(400).send({ ok: false, error: "bad ma_id" });
}
const r = await pool.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]);
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
return { ok: true, item: r.rows[0] };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur récupération band" });
}
});
fastify.patch("/admin/api/bands/:ma_id", async (req, reply) => {
try {
const id = Number(req.params.ma_id);
if (!Number.isFinite(id) || id < 0) {
return reply.code(400).send({ ok: false, error: "bad ma_id" });
}
const body = req.body || {};
const updates = {};
for (const field of BAND_EDITABLE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(body, field)) {
updates[field] = body[field];
}
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ ok: false, error: "Aucun champ à modifier" });
}
if ("formed_year" in updates) {
const y = updates.formed_year === null ? null : Number(updates.formed_year);
if (y !== null && (!Number.isFinite(y) || y < 1800 || y > 2100)) {
return reply.code(400).send({ ok: false, error: "formed_year invalide" });
}
updates.formed_year = y;
}
if ("lat" in updates) {
const v = updates.lat === null ? null : Number(updates.lat);
if (v !== null && (!Number.isFinite(v) || v < -90 || v > 90)) {
return reply.code(400).send({ ok: false, error: "lat invalide" });
}
updates.lat = v;
}
if ("lon" in updates) {
const v = updates.lon === null ? null : Number(updates.lon);
if (v !== null && (!Number.isFinite(v) || v < -180 || v > 180)) {
return reply.code(400).send({ ok: false, error: "lon invalide" });
}
updates.lon = v;
}
const before = await pool.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]);
if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
const setCols = Object.keys(updates);
const setSql = setCols.map((c, idx) => `${c} = $${idx + 2}`).join(", ");
const vals = [id, ...setCols.map((c) => updates[c])];
const after = await pool.query(
`UPDATE bands SET ${setSql} WHERE ma_id = $1 RETURNING *`,
vals
);
await writeAuditLog(
pool, req.adminUsername, "update", "bands", id,
before.rows[0], after.rows[0]
);
return { ok: true, item: after.rows[0] };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur mise à jour band" });
}
});
// ------------------------------------------------------------------
// Crawl runs / checkpoints
// ------------------------------------------------------------------
fastify.get("/admin/api/crawl-runs", async (req, reply) => {
try {
const { run_type, status } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
let i = 1;
if (run_type) { where.push(`run_type = $${i}`); vals.push(run_type); i++; }
if (status) { where.push(`status = $${i}`); vals.push(status); i++; }
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const countRes = await pool.query(`SELECT count(*)::int AS total FROM crawl_run ${whereSql}`, vals);
vals.push(pageSize, offset);
const dataRes = await pool.query(`
SELECT id, run_type, countries, status, started_at, finished_at,
bands_seen, bands_new, bands_updated, bands_enriched, error
FROM crawl_run ${whereSql}
ORDER BY started_at DESC
LIMIT $${i} OFFSET $${i + 1}
`, vals);
return { ok: true, items: dataRes.rows, total: countRes.rows[0].total, page, pageSize };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur crawl runs" });
}
});
fastify.get("/admin/api/crawl-checkpoints", async (req, reply) => {
try {
const r = await pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`);
return { ok: true, items: r.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur checkpoints" });
}
});
// ------------------------------------------------------------------
// Logs crawler
// ------------------------------------------------------------------
fastify.get("/admin/api/logs", async (req, reply) => {
try {
const { level, run_id, since } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
let i = 1;
if (level) { where.push(`level = $${i}`); vals.push(level); i++; }
if (run_id) { where.push(`run_id = $${i}`); vals.push(Number(run_id)); i++; }
if (since) { where.push(`created_at > $${i}`); vals.push(since); i++; }
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
vals.push(pageSize, offset);
const r = await pool.query(`
SELECT id, run_id, level, message, ma_id, created_at
FROM crawl_log ${whereSql}
ORDER BY created_at DESC
LIMIT $${i} OFFSET $${i + 1}
`, vals);
return { ok: true, items: r.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur logs" });
}
});
// ------------------------------------------------------------------
// Journal d'audit (actions admin)
// ------------------------------------------------------------------
fastify.get("/admin/api/audit-log", async (req, reply) => {
try {
const { page, pageSize, offset } = pagination(req.query || {});
const r = await pool.query(`
SELECT id, admin_username, action, target_table, target_id, before_data, after_data, created_at
FROM admin_audit_log
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, [pageSize, offset]);
return { ok: true, items: r.rows, page, pageSize };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur audit log" });
}
});
}

View file

@ -2,13 +2,26 @@ import Fastify from "fastify";
import pg from "pg"; import pg from "pg";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import helmet from "@fastify/helmet"; import helmet from "@fastify/helmet";
import cookie from "@fastify/cookie";
import { timingSafeEqual } from "crypto"; import { timingSafeEqual } from "crypto";
import {
ADMIN_COOKIE_NAME,
seedAdminUser,
signAdminSession,
isLockedOut,
recordLoginAttempt,
verifyPassword,
markLoginSuccess,
requireAdminSession,
} from "./adminAuth.js";
import adminApiRoutes from "./adminRoutes.js";
const { Pool } = pg; const { Pool } = pg;
const fastify = Fastify({ const fastify = Fastify({
logger: true, logger: true,
bodyLimit: 10485760 // 10 MB max pour éviter DoS mémoire bodyLimit: 10485760, // 10 MB max pour éviter DoS mémoire
trustProxy: true // derrière Traefik (+ nginx pour /admin/*) : lit X-Forwarded-For
}); });
// Headers de sécurité // Headers de sécurité
@ -17,6 +30,8 @@ await fastify.register(helmet, {
crossOriginEmbedderPolicy: false crossOriginEmbedderPolicy: false
}); });
await fastify.register(cookie);
// CORS middleware // CORS middleware
fastify.addHook('onRequest', async (request, reply) => { fastify.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin; const origin = request.headers.origin;
@ -59,10 +74,11 @@ const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim();
let pool = null; let pool = null;
if (DATABASE_URL) { if (DATABASE_URL) {
pool = new Pool({ pool = new Pool({
connectionString: DATABASE_URL, connectionString: DATABASE_URL,
statement_timeout: 60000 statement_timeout: 60000
}); });
seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed"));
} }
function requirePool() { function requirePool() {
@ -717,4 +733,65 @@ fastify.register(async function(adminRoutes) {
}); });
}); });
// ------------------------------------------------------------------
// Auth dashboard admin (session cookie, distincte du token BM_IMPORT_TOKEN)
// ------------------------------------------------------------------
fastify.register(async function (authRoutes) {
await authRoutes.register(rateLimit, {
max: 10,
timeWindow: "1 minute",
keyGenerator: (req) => req.ip,
});
authRoutes.post("/admin/auth/login", async (req, reply) => {
try {
const p = requirePool();
const { username, password } = req.body || {};
if (typeof username !== "string" || typeof password !== "string" || !username || !password) {
return reply.code(400).send({ ok: false, error: "username et password requis" });
}
const uname = username.trim().slice(0, 100);
const ip = req.ip;
if (await isLockedOut(p, uname, ip)) {
return reply.code(429).send({ ok: false, error: "Trop de tentatives, réessayez dans quelques minutes" });
}
const valid = await verifyPassword(p, uname, password);
await recordLoginAttempt(p, uname, ip, valid);
if (!valid) {
return reply.code(401).send({ ok: false, error: "Identifiants invalides" });
}
await markLoginSuccess(p, uname);
const token = signAdminSession(uname);
reply.setCookie(ADMIN_COOKIE_NAME, token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 12 * 3600,
});
return { ok: true, username: uname };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur de connexion" });
}
});
authRoutes.post("/admin/auth/logout", async (req, reply) => {
reply.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
return { ok: true };
});
authRoutes.get("/admin/auth/me", async (req, reply) => {
const username = requireAdminSession(req, reply);
if (!username) return;
return { ok: true, username };
});
});
await fastify.register(adminApiRoutes, { pool });
fastify.listen({ port: PORT, host: "0.0.0.0" }); fastify.listen({ port: PORT, host: "0.0.0.0" });

View file

@ -175,6 +175,23 @@ def get_bands_to_enrich(country: Optional[str] = None, limit: int = 50) -> List[
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
# ------------------------------------------------------------------
# Logs (visibles dans le dashboard admin)
# ------------------------------------------------------------------
def log_event(level: str, message: str, run_id: Optional[int] = None, ma_id: Optional[int] = None):
"""Écrit une ligne de log en DB pour le dashboard admin (n'interrompt jamais le crawl)."""
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO crawl_log (run_id, level, message, ma_id) VALUES (%s, %s, %s, %s)",
(run_id, level, message[:2000], ma_id),
)
except Exception as e:
log.warning(f"[db] log_event failed: {e}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Crawl run tracking # Crawl run tracking
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -15,6 +15,7 @@ from .db import (
get_bands_to_enrich, get_bands_to_enrich,
start_crawl_run, finish_crawl_run, start_crawl_run, finish_crawl_run,
get_checkpoint, set_checkpoint, get_checkpoint, set_checkpoint,
log_event,
) )
from .europe_codes import EUROPE_COUNTRY_CODES from .europe_codes import EUROPE_COUNTRY_CODES
from .ma_http import MASession from .ma_http import MASession
@ -35,6 +36,7 @@ def run_full_crawl(session: MASession, countries: List[str] = None):
run_id = start_crawl_run("full_europe", countries) run_id = start_crawl_run("full_europe", countries)
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0} stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
error = None error = None
log_event("info", f"full crawl started ({len(countries)} countries)", run_id=run_id)
try: try:
for cc in countries: for cc in countries:
@ -57,9 +59,11 @@ def run_full_crawl(session: MASession, countries: List[str] = None):
set_checkpoint("last_full_crawl_at", _now_iso()) set_checkpoint("last_full_crawl_at", _now_iso())
log.info(f"[full] done: {stats}") log.info(f"[full] done: {stats}")
log_event("info", f"full crawl done: {stats}", run_id=run_id)
except Exception as e: except Exception as e:
error = str(e) error = str(e)
log.error(f"[full] error: {e}", exc_info=True) log.error(f"[full] error: {e}", exc_info=True)
log_event("error", f"full crawl error: {e}", run_id=run_id)
finally: finally:
finish_crawl_run(run_id, stats, error) finish_crawl_run(run_id, stats, error)
@ -81,6 +85,7 @@ def run_incremental(session: MASession, order_by: str):
latest_date = since latest_date = since
log.info(f"[incr/{order_by}] since={since}") log.info(f"[incr/{order_by}] since={since}")
log_event("info", f"incremental/{order_by} started (since={since})", run_id=run_id)
try: try:
buf = [] buf = []
for band in session.fetch_archive_bands(order_by, since_date=since): for band in session.fetch_archive_bands(order_by, since_date=since):
@ -106,9 +111,11 @@ def run_incremental(session: MASession, order_by: str):
if latest_date: if latest_date:
set_checkpoint(checkpoint_key, latest_date) set_checkpoint(checkpoint_key, latest_date)
log.info(f"[incr/{order_by}] done: {stats}") log.info(f"[incr/{order_by}] done: {stats}")
log_event("info", f"incremental/{order_by} done: {stats}", run_id=run_id)
except Exception as e: except Exception as e:
error = str(e) error = str(e)
log.error(f"[incr/{order_by}] error: {e}", exc_info=True) log.error(f"[incr/{order_by}] error: {e}", exc_info=True)
log_event("error", f"incremental/{order_by} error: {e}", run_id=run_id)
finally: finally:
finish_crawl_run(run_id, stats, error) finish_crawl_run(run_id, stats, error)
@ -129,6 +136,7 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
try: try:
bands = get_bands_to_enrich(country=country, limit=limit) bands = get_bands_to_enrich(country=country, limit=limit)
log.info(f"[enrich] {len(bands)} bands to enrich") log.info(f"[enrich] {len(bands)} bands to enrich")
log_event("info", f"enrich started: {len(bands)} bands queued", run_id=run_id)
for i, band in enumerate(bands): for i, band in enumerate(bands):
url = band.get("url") url = band.get("url")
@ -147,6 +155,7 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
stats["seen"] += 1 stats["seen"] += 1
except Exception as e: except Exception as e:
log.warning(f"[enrich] failed ma_id={band['ma_id']}: {e}") log.warning(f"[enrich] failed ma_id={band['ma_id']}: {e}")
log_event("warning", f"enrich failed: {e}", run_id=run_id, ma_id=band["ma_id"])
continue continue
sleep_range(BAND_MIN_DELAY, BAND_MAX_DELAY) sleep_range(BAND_MIN_DELAY, BAND_MAX_DELAY)
@ -154,9 +163,11 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
cooldown(COOLDOWN_MIN, COOLDOWN_MAX) cooldown(COOLDOWN_MIN, COOLDOWN_MAX)
log.info(f"[enrich] done: {stats}") log.info(f"[enrich] done: {stats}")
log_event("info", f"enrich done: {stats}", run_id=run_id)
except Exception as e: except Exception as e:
error = str(e) error = str(e)
log.error(f"[enrich] error: {e}", exc_info=True) log.error(f"[enrich] error: {e}", exc_info=True)
log_event("error", f"enrich error: {e}", run_id=run_id)
finally: finally:
finish_crawl_run(run_id, stats, error) finish_crawl_run(run_id, stats, error)

View file

@ -81,6 +81,9 @@ services:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
CORS_ORIGINS: ${CORS_ORIGINS:-https://dev.metalfrom.eu} CORS_ORIGINS: ${CORS_ORIGINS:-https://dev.metalfrom.eu}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
ADMIN_SEED_USERNAME: ${ADMIN_SEED_USERNAME}
ADMIN_SEED_PASSWORD_HASH: ${ADMIN_SEED_PASSWORD_HASH}
networks: networks:
- coolify - coolify
labels: labels:
@ -92,6 +95,20 @@ services:
- traefik.http.routers.dev-api.tls.certresolver=letsencrypt - traefik.http.routers.dev-api.tls.certresolver=letsencrypt
- traefik.http.services.dev-api.loadbalancer.server.port=3000 - traefik.http.services.dev-api.loadbalancer.server.port=3000
admin:
build:
context: apps/admin
networks:
- coolify
labels:
- traefik.enable=true
- traefik.docker.network=coolify
- traefik.http.routers.dev-admin.rule=Host(`admin.dev.metalfrom.eu`)
- traefik.http.routers.dev-admin.entrypoints=https
- traefik.http.routers.dev-admin.tls=true
- traefik.http.routers.dev-admin.tls.certresolver=letsencrypt
- traefik.http.services.dev-admin.loadbalancer.server.port=80
web: web:
build: build:
context: apps/web context: apps/web

View file

@ -53,6 +53,9 @@ services:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
CORS_ORIGINS: ${CORS_ORIGINS:-https://metalfrom.eu,https://www.metalfrom.eu} CORS_ORIGINS: ${CORS_ORIGINS:-https://metalfrom.eu,https://www.metalfrom.eu}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
ADMIN_SEED_USERNAME: ${ADMIN_SEED_USERNAME}
ADMIN_SEED_PASSWORD_HASH: ${ADMIN_SEED_PASSWORD_HASH}
networks: networks:
- coolify - coolify
labels: labels:
@ -64,6 +67,20 @@ services:
- traefik.http.routers.bm-api.tls.certresolver=letsencrypt - traefik.http.routers.bm-api.tls.certresolver=letsencrypt
- traefik.http.services.bm-api.loadbalancer.server.port=3000 - traefik.http.services.bm-api.loadbalancer.server.port=3000
admin:
build:
context: apps/admin
networks:
- coolify
labels:
- traefik.enable=true
- traefik.docker.network=coolify
- traefik.http.routers.bm-admin.rule=Host(`admin.metalfrom.eu`)
- traefik.http.routers.bm-admin.entrypoints=https
- traefik.http.routers.bm-admin.tls=true
- traefik.http.routers.bm-admin.tls.certresolver=letsencrypt
- traefik.http.services.bm-admin.loadbalancer.server.port=80
web: web:
build: build:
context: apps/web context: apps/web