metalfrom.eu/apps/api/src/server.js
Nicolas Fryder a7d7bc94de 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>
2026-06-30 22:15:13 +02:00

797 lines
No EOL
22 KiB
JavaScript

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({
logger: true,
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é
await fastify.register(helmet, {
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
});
await fastify.register(cookie);
// CORS middleware
fastify.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin;
const allowedOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(s => s.trim())
: ['https://metalfrom.eu', 'https://www.metalfrom.eu'];
if (allowedOrigins.includes(origin)) {
reply.header('Access-Control-Allow-Origin', origin);
}
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
reply.header('Access-Control-Allow-Credentials', 'true');
if (request.method === 'OPTIONS') {
reply.status(204).send();
}
});
// Rate limiting global
await fastify.register(rateLimit, {
max: 1000,
timeWindow: '1 minute',
cache: 10000
});
// Gestion d'erreurs globale
fastify.setErrorHandler((error, request, reply) => {
fastify.log.error(error);
reply.status(500).send({
ok: false,
error: 'Une erreur est survenue'
});
});
const PORT = Number(process.env.PORT || 3000);
const DATABASE_URL = process.env.DATABASE_URL;
const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim();
let pool = null;
if (DATABASE_URL) {
pool = new Pool({
connectionString: DATABASE_URL,
statement_timeout: 60000
});
seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed"));
}
function requirePool() {
if (!pool) throw new Error("DATABASE_URL not set");
return pool;
}
function authBearer(req) {
const h = req.headers.authorization || "";
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1] : null;
}
function requireAdmin(req, reply) {
const provided = authBearer(req);
if (!BM_IMPORT_TOKEN || !provided || provided.length !== BM_IMPORT_TOKEN.length) {
reply.code(401).send({ ok: false, error: "unauthorized" });
return false;
}
const valid = timingSafeEqual(
Buffer.from(provided),
Buffer.from(BM_IMPORT_TOKEN)
);
if (!valid) {
reply.code(401).send({ ok: false, error: "unauthorized" });
return false;
}
return true;
}
function sanitizeSearchString(str, fieldName = 'champ') {
const trimmed = String(str).trim();
if (trimmed.length < 2 || trimmed.length > 100) {
throw new Error(`${fieldName} doit faire entre 2 et 100 caractères`);
}
if (/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(trimmed)) {
throw new Error(`${fieldName} contient des caractères invalides`);
}
if (/%%%|_{10,}|%{10,}/.test(trimmed)) {
throw new Error(`${fieldName} contient des patterns invalides`);
}
return trimmed;
}
fastify.get("/", async () => {
return { ok: true, service: "BM API" };
});
fastify.get("/api/health", async () => ({ ok: true }));
fastify.get("/api/db", async (req, reply) => {
try {
const p = requirePool();
const r = await p.query("SELECT now() as now, current_database() as db");
return { ok: true, ...r.rows[0] };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur de connexion à la base de données' });
}
});
fastify.get("/api/stats", async (req, reply) => {
try {
const p = requirePool();
const q = `
SELECT
count(*)::int as total,
count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
count(*) FILTER (WHERE geom IS NULL)::int as no_location,
count(*) FILTER (WHERE enriched = true)::int as enriched
FROM bands;
`;
const r = await p.query(q);
return { ok: true, ...r.rows[0] };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des stats' });
}
});
fastify.get("/api/countries", async (req, reply) => {
try {
const p = requirePool();
const q = `
SELECT
country,
count(*)::int as total,
count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
count(*) FILTER (WHERE enriched = true)::int as enriched
FROM bands
GROUP BY country
ORDER BY total DESC;
`;
const r = await p.query(q);
return { ok: true, items: r.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des pays' });
}
});
fastify.get("/api/statuses", async (req, reply) => {
try {
const p = requirePool();
const q = `
SELECT
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
count(*)::int as total,
count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded
FROM bands
GROUP BY 1
ORDER BY total DESC;
`;
const r = await p.query(q);
return { ok: true, items: r.rows };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des statuts' });
}
});
fastify.get("/api/clusters", async (req, reply) => {
try {
const p = requirePool();
const {
bbox,
zoom,
countries,
status,
genre,
year_min,
year_max,
} = req.query || {};
if (!bbox || !zoom) {
return reply.code(400).send({ ok: false, error: "bbox and zoom are required" });
}
const bboxParts = String(bbox).split(",").map(Number);
if (bboxParts.length !== 4) {
return reply.code(400).send({ ok: false, error: "bbox must have 4 values" });
}
const [minLon, minLat, maxLon, maxLat] = bboxParts;
const zoomLevel = Number(zoom);
if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" });
}
if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) {
return reply.code(400).send({ ok: false, error: "Coordinates out of range" });
}
if (minLon >= maxLon || minLat >= maxLat) {
return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" });
}
if (zoomLevel < 0 || zoomLevel > 22) {
return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" });
}
const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
const where = ["geom IS NOT NULL"];
const vals = [];
let i = 1;
where.push(`ST_X(geom::geometry) >= $${i} AND ST_X(geom::geometry) <= $${i+1} AND ST_Y(geom::geometry) >= $${i+2} AND ST_Y(geom::geometry) <= $${i+3}`);
vals.push(minLon, maxLon, minLat, maxLat);
i += 4;
if (countries) {
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
if (cs.length > 100) {
return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
}
if (cs.length) {
where.push(`country = ANY($${i}::text[])`);
vals.push(cs);
i++;
}
}
if (status) {
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
if (st.length > 50) {
return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
}
if (st.length) {
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
vals.push(st);
i++;
}
}
if (genre) {
try {
const genreQuery = sanitizeSearchString(genre, 'Genre');
where.push(`genre ILIKE $${i}`);
vals.push(`%${genreQuery}%`);
i++;
} catch (err) {
return reply.code(400).send({ ok: false, error: err.message });
}
}
if (year_min) {
const yearMin = Number(year_min);
if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) {
return reply.code(400).send({ ok: false, error: 'year_min invalide' });
}
where.push(`formed_year >= $${i}`);
vals.push(yearMin);
i++;
}
if (year_max) {
const yearMax = Number(year_max);
if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) {
return reply.code(400).send({ ok: false, error: 'year_max invalide' });
}
where.push(`formed_year <= $${i}`);
vals.push(yearMax);
i++;
}
const whereSql = where.join(" AND ");
if (zoomLevel >= 12) {
const sql = `
SELECT
ma_id,
name,
country,
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
genre,
location_text,
formed_year,
lat,
lon
FROM bands
WHERE ${whereSql}
ORDER BY ma_id ASC
LIMIT 2000;
`;
const r = await p.query(sql, vals);
return {
ok: true,
type: "bands",
items: r.rows,
count: r.rows.length
};
}
const sql = `
WITH grid_cells AS (
SELECT
floor(ST_X(geom::geometry) / $${i})::int as cell_x,
floor(ST_Y(geom::geometry) / $${i})::int as cell_y,
ma_id,
name,
country,
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
genre,
location_text,
formed_year,
lat,
lon
FROM bands
WHERE ${whereSql}
),
clusters AS (
SELECT
cell_x,
cell_y,
count(*)::int as band_count,
avg(lat)::float8 as center_lat,
avg(lon)::float8 as center_lon,
array_agg(json_build_object(
'ma_id', ma_id,
'name', name,
'country', country,
'status', status,
'genre', genre,
'location_text', location_text,
'formed_year', formed_year,
'lat', lat,
'lon', lon
) ORDER BY name) as bands
FROM grid_cells
GROUP BY cell_x, cell_y
)
SELECT
center_lat as lat,
center_lon as lon,
band_count as count,
CASE
WHEN band_count <= 5 THEN bands
ELSE bands[1:5]
END as sample_bands
FROM clusters
ORDER BY band_count DESC
LIMIT 1000;
`;
vals.push(cellSize);
const r = await p.query(sql, vals);
return {
ok: true,
type: "clusters",
items: r.rows,
total_clusters: r.rows.length,
cell_size: cellSize,
zoom: zoomLevel
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des clusters' });
}
});
fastify.get("/api/bands", async (req, reply) => {
try {
const p = requirePool();
const {
countries,
geocoded,
q,
status,
only_black,
limit,
offset,
} = req.query || {};
const lim = Math.min(Number(limit || 1000), 150000);
const off = Math.max(Number(offset || 0), 0);
if (off > 1000000) {
return reply.code(400).send({
ok: false,
error: "Offset trop grand (max 1 million)"
});
}
if (lim > 10000) {
fastify.log.warn(`Large query: ${lim} rows requested from ${req.ip}`);
}
const where = [];
const vals = [];
let i = 1;
if (countries) {
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
if (cs.length > 100) {
return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
}
if (cs.length) {
where.push(`country = ANY($${i}::text[])`);
vals.push(cs);
i++;
}
}
if (geocoded === "1") where.push(`geom IS NOT NULL`);
if (geocoded === "0") where.push(`geom IS NULL`);
if (status) {
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
if (st.length > 50) {
return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
}
if (st.length) {
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
vals.push(st);
i++;
}
}
if (only_black === "1") {
where.push(`genre ILIKE '%black%'`);
}
if (q) {
try {
const query = sanitizeSearchString(q, 'Recherche');
const qq = `%${query}%`;
where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`);
vals.push(qq);
i++;
} catch (err) {
return reply.code(400).send({ ok: false, error: err.message });
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const sql = `
SELECT
ma_id,
name,
country,
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
genre,
location_text,
enriched,
formed_year,
themes,
lat,
lon
FROM bands
${whereSql}
ORDER BY ma_id ASC
LIMIT $${i} OFFSET $${i+1};
`;
vals.push(lim, off);
const r = await p.query(sql, vals);
return {
ok: true,
items: r.rows,
limit: lim,
offset: off
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la recherche' });
}
});
fastify.get("/api/facets", async (req, reply) => {
try {
const p = requirePool();
const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
p.query(`
SELECT
COALESCE(NULLIF(trim(status), ''), 'Unknown') as value,
count(*)::int as count
FROM bands
WHERE geom IS NOT NULL
GROUP BY 1
ORDER BY count DESC
`),
p.query(`
SELECT
COALESCE(country, '??') as value,
count(*)::int as count
FROM bands
WHERE geom IS NOT NULL
GROUP BY 1
ORDER BY count DESC
`),
p.query(`
SELECT
genre as value,
count(*)::int as count
FROM bands
WHERE geom IS NOT NULL
AND genre IS NOT NULL
AND genre != ''
GROUP BY 1
ORDER BY count DESC
LIMIT 500
`),
p.query(`
SELECT
MIN(formed_year)::int as min_year,
MAX(formed_year)::int as max_year
FROM bands
WHERE geom IS NOT NULL
AND formed_year >= 1900
AND formed_year <= extract(year from now())
`)
]);
return {
ok: true,
statuses: statusRes.rows,
countries: countryRes.rows,
genres: genreRes.rows,
year_range: yearRes.rows[0] || { min_year: null, max_year: null }
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des facettes' });
}
});
fastify.get("/api/band/:ma_id", async (req, reply) => {
try {
const p = requirePool();
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 p.query(
`SELECT
ma_id,
name,
country,
status,
genre,
location_text,
enriched,
data,
formed_year,
themes,
lat,
lon,
geocoded_at
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 lors de la récupération du groupe' });
}
});
fastify.register(async function(adminRoutes) {
await adminRoutes.register(rateLimit, {
max: 10,
timeWindow: '1 minute',
keyGenerator: (req) => {
return authBearer(req) || req.ip;
}
});
adminRoutes.post("/admin/import", async (req, reply) => {
if (!requireAdmin(req, reply)) return;
try {
const body = req.body;
if (!body || typeof body !== "object") {
return reply.code(400).send({ ok: false, error: "invalid body/json" });
}
const bands = Array.isArray(body.bands) ? body.bands : null;
if (!bands) {
return reply.code(400).send({ ok: false, error: "missing bands[]" });
}
if (bands.length > 1000) {
return reply.code(400).send({
ok: false,
error: "Max 1000 bands par import"
});
}
const p = requirePool();
let upserted = 0;
for (const b of bands) {
if (!b || typeof b !== "object") continue;
const ma_id = Number(b.ma_id);
if (!Number.isFinite(ma_id)) continue;
const name = b.name ?? null;
const country = b.country ?? null;
const status = b.status ?? null;
const genre = b.genre ?? null;
const location_text = b.location_text ?? b.location ?? null;
const hasData = b.data && typeof b.data === "object";
const data = hasData ? b.data : null;
const enriched = hasData ? true : (b.enriched ?? null);
await p.query(
`
INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched)
VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false))
ON CONFLICT (ma_id) DO UPDATE SET
name = COALESCE(EXCLUDED.name, bands.name),
country = COALESCE(EXCLUDED.country, bands.country),
status = COALESCE(EXCLUDED.status, bands.status),
genre = COALESCE(EXCLUDED.genre, bands.genre),
location_text = COALESCE(EXCLUDED.location_text, bands.location_text),
data = COALESCE(EXCLUDED.data, bands.data),
enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
`,
[ma_id, name, country, status, genre, location_text, data, enriched]
);
upserted++;
}
return reply.send({ ok: true, upserted });
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de l\'import' });
}
});
adminRoutes.get("/admin/enrich/next", async (req, reply) => {
if (!requireAdmin(req, reply)) return;
try {
const p = requirePool();
const limitRaw = Number(req.query?.limit ?? 50);
const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500));
const country = (req.query?.country ?? "").toString().trim().toUpperCase();
const params = [];
let where = "WHERE (data->'band_page') IS NULL";
if (country) {
params.push(country);
where += ` AND country = $${params.length}`;
}
params.push(limit);
const sql = `
SELECT
ma_id,
(data->>'url') AS url
FROM bands
${where}
ORDER BY ma_id ASC
LIMIT $${params.length}
`;
const r = await p.query(sql, params);
const items = (r.rows || []).filter((x) => x.url);
return { ok: true, count: items.length, items };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des groupes à enrichir' });
}
});
});
// ------------------------------------------------------------------
// 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" });