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)); // Source: band_locations (un point par localisation géocodée) JOIN bands. const where = [ "bl.geom IS NOT NULL", "bl.geocode_status IN ('done','country_only')", ]; const vals = []; let i = 1; where.push(`bl.geom && ST_MakeEnvelope($${i}, $${i+1}, $${i+2}, $${i+3}, 4326)`); vals.push(minLon, minLat, maxLon, 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(`b.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(b.status), ''), 'Unknown') = ANY($${i}::text[])`); vals.push(st); i++; } } if (genre) { try { const genreQuery = sanitizeSearchString(genre, 'Genre'); where.push(`b.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(`b.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(`b.formed_year <= $${i}`); vals.push(yearMax); i++; } const whereSql = where.join(" AND "); if (zoomLevel >= 12) { const sql = ` SELECT b.ma_id, b.name, b.country, COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status, b.genre, b.location_text, b.formed_year, bl.lat, bl.lon, bl.location_raw, bl.step_order, bl.step_label, bl.is_country_only FROM band_locations bl JOIN bands b ON b.ma_id = bl.ma_id WHERE ${whereSql} ORDER BY bl.ma_id ASC, bl.step_order 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(bl.geom) / $${i})::int as cell_x, floor(ST_Y(bl.geom) / $${i})::int as cell_y, b.ma_id, b.name, b.country, COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status, b.genre, b.location_text, b.formed_year, bl.lat, bl.lon, bl.location_raw, bl.step_label FROM band_locations bl JOIN bands b ON b.ma_id = bl.ma_id 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, 'location_raw', location_raw, 'step_label', step_label ) 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" });