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:
parent
891285a572
commit
a7d7bc94de
15 changed files with 2433 additions and 4 deletions
3
apps/admin/Dockerfile
Normal file
3
apps/admin/Dockerfile
Normal 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
25
apps/admin/nginx.conf
Normal 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
577
apps/admin/site/app.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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">×</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();
|
||||||
52
apps/admin/site/index.html
Normal file
52
apps/admin/site/index.html
Normal 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
293
apps/admin/site/styles.css
Normal 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; }
|
||||||
42
apps/api/migrations/006_admin_dashboard.sql
Normal file
42
apps/api/migrations/006_admin_dashboard.sql
Normal 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
852
apps/api/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
98
apps/api/src/adminAuth.js
Normal 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
345
apps/api/src/adminRoutes.js
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -63,6 +78,7 @@ if (DATABASE_URL) {
|
||||||
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" });
|
||||||
|
|
@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue