diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile new file mode 100644 index 0000000..beaf368 --- /dev/null +++ b/apps/admin/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY site/ /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf new file mode 100644 index 0000000..871a3f8 --- /dev/null +++ b/apps/admin/nginx.conf @@ -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; + } +} diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js new file mode 100644 index 0000000..0965d4b --- /dev/null +++ b/apps/admin/site/app.js @@ -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 = `
Chargement…
`; + 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 = ` +
+ ${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")} +
+
+
+

Par statut

+ ${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()} +
+
+

Par pays (top 20)

+ ${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()} +
+
+

Par genre (top 20)

+ ${r.by_genre.map((x) => barRow(x.genre, x.total, maxGenre)).join("") || emptyRow()} +
+
+ `; + } catch (e) { + content().innerHTML = `
Erreur : ${esc(e.message)}
`; + } +} + +function statCard(label, value, cls = "") { + return `
${esc(label)}
${Number(value).toLocaleString("fr-FR")}
`; +} +function barRow(label, count, max) { + const pct = Math.max(2, Math.round((count / max) * 100)); + return `
${esc(label)}
${count}
`; +} +function emptyRow() { return `
Aucune donnée
`; } + +// ------------------------------------------------------------------ +// Queue +// ------------------------------------------------------------------ +async function renderQueue() { + content().innerHTML = `
Chargement…
`; + try { + const r = await api("/admin/api/queue"); + const b = r.breakdown; + content().innerHTML = ` +
+ ${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")} +
+
+

Derniers runs d'enrichissement

+
+ + + ${r.recent_enrich_runs.map((x) => ` + + + + + + + + + + `).join("") || ``} + +
IDStatutDébutFinVusEnrichisErreur
${x.id}${statusBadge(x.status)}${fmtDate(x.started_at)}${fmtDate(x.finished_at)}${x.bands_seen}${x.bands_enriched}${esc(x.error || "")}
Aucun run
+
+ `; + } catch (e) { + content().innerHTML = `
Erreur : ${esc(e.message)}
`; + } +} + +function statusBadge(status) { + const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted"; + return `${esc(status)}`; +} + +// ------------------------------------------------------------------ +// Bands +// ------------------------------------------------------------------ +async function renderBands() { + const s = state.bands; + content().innerHTML = ` +
+ + + + + + +
+
Chargement…
+
+ `; + 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 = ` + + + ${BAND_COLUMNS.map((c) => ``).join("")} + + + ${r.items.map((b) => ` + + + + + + + + + + + + `).join("") || ``} + +
${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}
${b.ma_id}${esc(b.name)}${esc(b.country || "")}${esc(b.status || "")}${esc(b.genre || "")}${b.formed_year || ""}${b.enriched ? 'oui' : 'non'}${fmtDate(b.crawled_at)}${fmtDate(b.updated_at)}
Aucun résultat
+ `; + 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 = ` + + Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")} bands) + + `; + 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 = `
Erreur : ${esc(e.message)}
`; + } +} + +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 = ` + + `; + 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 = ` +
+ + + +
+
Chargement…
+
+ `; + 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 = ` + + + + ${r.items.map((x) => ` + + + + + + + + + + + + + + `).join("") || ``} + +
IDTypePaysStatutDébutFinVusNouveauxMAJEnrichisErreur
${x.id}${esc(x.run_type)}${esc((x.countries || []).join(", "))}${statusBadge(x.status)}${fmtDate(x.started_at)}${fmtDate(x.finished_at)}${x.bands_seen}${x.bands_new}${x.bands_updated}${x.bands_enriched}${esc(x.error || "")}
Aucun run
+ `; + const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize)); + document.getElementById("r-pager").innerHTML = ` + + Page ${s.page} / ${totalPages} (${s.total} runs) + + `; + 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 = `
Erreur : ${esc(e.message)}
`; + } +} + +// ------------------------------------------------------------------ +// Logs +// ------------------------------------------------------------------ +async function renderLogs() { + const s = state.logs; + content().innerHTML = ` +
+ + + +
+
Chargement…
+ `; + 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) => ` +
+ ${fmtDate(x.created_at)} + ${esc(x.level)} + ${esc(x.message)}${x.ma_id ? ` (ma_id=${x.ma_id})` : ""} +
+ `).join("") || `
Aucun log
`; + } catch (e) { + document.getElementById("l-list").innerHTML = `
Erreur : ${esc(e.message)}
`; + } +} + +// ------------------------------------------------------------------ +// Checkpoints +// ------------------------------------------------------------------ +async function renderCheckpoints() { + content().innerHTML = `
Chargement…
`; + try { + const r = await api("/admin/api/crawl-checkpoints"); + content().innerHTML = ` +
+ + + ${r.items.map((x) => ``).join("") || ``} + +
CléValeurMis à jour
${esc(x.key)}${esc(x.value || "—")}${fmtDate(x.updated_at)}
Aucun checkpoint
+ `; + } catch (e) { + content().innerHTML = `
Erreur : ${esc(e.message)}
`; + } +} + +// ------------------------------------------------------------------ +// Audit log +// ------------------------------------------------------------------ +async function renderAudit() { + content().innerHTML = `
Chargement…
`; + try { + const r = await api(`/admin/api/audit-log?page=1&pageSize=${state.audit.pageSize}`); + content().innerHTML = ` +
+ + + ${r.items.map((x) => ` + + + + + + + + `).join("") || ``} + +
DateAdminActionTableID cible
${fmtDate(x.created_at)}${esc(x.admin_username)}${esc(x.action)}${esc(x.target_table)}${esc(x.target_id)}
Aucune action
+ `; + } catch (e) { + content().innerHTML = `
Erreur : ${esc(e.message)}
`; + } +} + +// ------------------------------------------------------------------ +// Boot +// ------------------------------------------------------------------ +checkSession(); diff --git a/apps/admin/site/index.html b/apps/admin/site/index.html new file mode 100644 index 0000000..b2dad9e --- /dev/null +++ b/apps/admin/site/index.html @@ -0,0 +1,52 @@ + + + + + + +Admin — metalfrom.eu + + + + + + +
+
+
Admin
+ + + + + +
+
+ + + + + + diff --git a/apps/admin/site/styles.css b/apps/admin/site/styles.css new file mode 100644 index 0000000..39919aa --- /dev/null +++ b/apps/admin/site/styles.css @@ -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; } diff --git a/apps/api/migrations/006_admin_dashboard.sql b/apps/api/migrations/006_admin_dashboard.sql new file mode 100644 index 0000000..0775bb1 --- /dev/null +++ b/apps/api/migrations/006_admin_dashboard.sql @@ -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); diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json new file mode 100644 index 0000000..a67f88a --- /dev/null +++ b/apps/api/package-lock.json @@ -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" + } + } + } +} diff --git a/apps/api/package.json b/apps/api/package.json index 68441bd..e3d1715 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,9 @@ "fastify": "^4.28.1", "@fastify/rate-limit": "^9.0.0", "@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" } } diff --git a/apps/api/src/adminAuth.js b/apps/api/src/adminAuth.js new file mode 100644 index 0000000..299723d --- /dev/null +++ b/apps/api/src/adminAuth.js @@ -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] + ); +} diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js new file mode 100644 index 0000000..b18bc54 --- /dev/null +++ b/apps/api/src/adminRoutes.js @@ -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" }); + } + }); +} diff --git a/apps/api/src/server.js b/apps/api/src/server.js index 938ba74..0ad2ad9 100644 --- a/apps/api/src/server.js +++ b/apps/api/src/server.js @@ -2,13 +2,26 @@ import Fastify from "fastify"; import pg from "pg"; import rateLimit from "@fastify/rate-limit"; import helmet from "@fastify/helmet"; +import cookie from "@fastify/cookie"; 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 fastify = Fastify({ +const fastify = Fastify({ 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é @@ -17,6 +30,8 @@ await fastify.register(helmet, { crossOriginEmbedderPolicy: false }); +await fastify.register(cookie); + // CORS middleware fastify.addHook('onRequest', async (request, reply) => { const origin = request.headers.origin; @@ -59,10 +74,11 @@ const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim(); let pool = null; if (DATABASE_URL) { - pool = new Pool({ + pool = new Pool({ connectionString: DATABASE_URL, statement_timeout: 60000 }); + seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed")); } 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" }); \ No newline at end of file diff --git a/apps/crawler/src/db.py b/apps/crawler/src/db.py index 496e6a9..3e00f83 100644 --- a/apps/crawler/src/db.py +++ b/apps/crawler/src/db.py @@ -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()] +# ------------------------------------------------------------------ +# 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 # ------------------------------------------------------------------ diff --git a/apps/crawler/src/jobs.py b/apps/crawler/src/jobs.py index 79cee0f..ed161c1 100644 --- a/apps/crawler/src/jobs.py +++ b/apps/crawler/src/jobs.py @@ -15,6 +15,7 @@ from .db import ( get_bands_to_enrich, start_crawl_run, finish_crawl_run, get_checkpoint, set_checkpoint, + log_event, ) from .europe_codes import EUROPE_COUNTRY_CODES 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) stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0} error = None + log_event("info", f"full crawl started ({len(countries)} countries)", run_id=run_id) try: 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()) log.info(f"[full] done: {stats}") + log_event("info", f"full crawl done: {stats}", run_id=run_id) except Exception as e: error = str(e) log.error(f"[full] error: {e}", exc_info=True) + log_event("error", f"full crawl error: {e}", run_id=run_id) finally: finish_crawl_run(run_id, stats, error) @@ -81,6 +85,7 @@ def run_incremental(session: MASession, order_by: str): latest_date = since log.info(f"[incr/{order_by}] since={since}") + log_event("info", f"incremental/{order_by} started (since={since})", run_id=run_id) try: buf = [] 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: set_checkpoint(checkpoint_key, latest_date) 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: error = str(e) 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: finish_crawl_run(run_id, stats, error) @@ -129,6 +136,7 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No try: bands = get_bands_to_enrich(country=country, limit=limit) 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): url = band.get("url") @@ -147,6 +155,7 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No stats["seen"] += 1 except Exception as 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 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) log.info(f"[enrich] done: {stats}") + log_event("info", f"enrich done: {stats}", run_id=run_id) except Exception as e: error = str(e) log.error(f"[enrich] error: {e}", exc_info=True) + log_event("error", f"enrich error: {e}", run_id=run_id) finally: finish_crawl_run(run_id, stats, error) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ac6e522..d0a7a17 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -81,6 +81,9 @@ services: DATABASE_URL: ${DATABASE_URL} BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} 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: - coolify labels: @@ -92,6 +95,20 @@ services: - traefik.http.routers.dev-api.tls.certresolver=letsencrypt - 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: build: context: apps/web diff --git a/docker-compose.yml b/docker-compose.yml index b4c11c2..967cc48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,9 @@ services: DATABASE_URL: ${DATABASE_URL} BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} 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: - coolify labels: @@ -64,6 +67,20 @@ services: - traefik.http.routers.bm-api.tls.certresolver=letsencrypt - 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: build: context: apps/web