From d241d168e3995545f272a155d48cc1fe27ff5e6c Mon Sep 17 00:00:00 2001 From: Nicolas Fryder Date: Sat, 27 Jun 2026 11:35:06 +0200 Subject: [PATCH] feat: integrer override api en production (supprimer mecanisme override) --- .claude/settings.local.json | 63 ++ apps/api/src/server.js | 1091 ++++++++++++++++----------- docker-compose.yml | 2 - infra/overrides/api/src/server.js | 718 ------------------ infra/overrides/api/src/server_a.js | 521 ------------- 5 files changed, 719 insertions(+), 1676 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 infra/overrides/api/src/server.js delete mode 100644 infra/overrides/api/src/server_a.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4653e89 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,63 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 debian@162.19.249.159 \"echo '=== SYSTEM ===' && uname -a && cat /etc/os-release && echo '=== MEMORY ===' && free -h && echo '=== DISK ===' && df -h && echo '=== CPU ===' && nproc && cat /proc/cpuinfo | grep 'model name' | head -1\")", + "Bash(sshpass -p 'Astatke2510!vps' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 debian@162.19.249.159 \"echo OK\")", + "PowerShell(python -c \"import paramiko; print\\('paramiko ok'\\)\" 2>$null)", + "PowerShell(pip install paramiko -q)", + "PowerShell(python -c \"import paramiko; print\\('ok'\\)\")", + "PowerShell(python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\audit_vps.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\audit_vps2.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\audit_vps3.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\setup_forgejo.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\fix_forgejo_compose.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\install_coolify.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\check_coolify.py\")", + "PowerShell($env:PYTHONIOENCODING = 'utf-8'; python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\reinstall_coolify.py\")", + "mcp__mcp-registry__search_mcp_registry", + "Bash(sshpass -p 'Astatke2510!vps' ssh -o StrictHostKeyChecking=no debian@162.19.249.159 'echo \"SSH OK: $\\(whoami\\)@$\\(hostname\\)\"')", + "Bash(which expect *)", + "Bash(ssh-keygen -t ed25519 -f /tmp/vps_key -N \"\" -C \"claude-session\")", + "Read(//tmp/**)", + "PowerShell(python \"C:\\\\Users\\\\nicol\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--docker-metalfromeu\\\\68e2630f-9d3e-452e-ad4d-20bc4a201ab8\\\\scratchpad\\\\add_key.py\")", + "Bash(ssh-keygen -t ed25519 -f C:/Users/nicol/AppData/Local/Temp/claude/C--docker-metalfromeu/68e2630f-9d3e-452e-ad4d-20bc4a201ab8/scratchpad/vps_key -N '' -C claude-session)", + "mcp__forgejo-full-api__forgejo_api_request", + "Bash(git clone *)", + "Bash(git init *)", + "Bash(git remote *)", + "Bash(git fetch *)", + "Bash(git checkout *)", + "Bash(git pull *)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/version)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/projects)", + "Bash(python3 -m json.tool)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/servers)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/projects/ntziu5sh17v7j9ijmze1foh7/environments)", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/databases/postgresql -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{ *)", + "Bash(curl -s -X PATCH https://coolify.nicolasfryder.ovh/api/v1/databases/lgrep99g3u2e37ib2xo9jlyk -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{\"postgres_password\": \"Astatke2510!db\"}')", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/storages -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{ *)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/storages)", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/s3 -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{\"name\":\"r2\",\"bucket\":\"metalfromeu-backups\"}')", + "Bash(git add *)", + "Bash(git restore *)", + "Bash(git commit -m ' *)", + "Bash(git config *)", + "Bash(git push *)", + "Bash(curl -s -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' https://coolify.nicolasfryder.ovh/api/v1/services)", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/applications/dockerfile -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{\"test\": true}')", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/applications/public -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{ *)", + "Bash(curl -v -X POST https://coolify.nicolasfryder.ovh/api/v1/applications/public -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{ *)", + "Bash(curl -s -X PATCH https://coolify.nicolasfryder.ovh/api/v1/applications/j14kfikjjif185likj5wnuay/envs/bulk -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{ *)", + "Bash(curl -s -X POST https://coolify.nicolasfryder.ovh/api/v1/applications/j14kfikjjif185likj5wnuay/deploy -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -H 'Content-Type: application/json' -d '{\"force\": false}')", + "Bash(curl -s -X GET https://coolify.nicolasfryder.ovh/api/v1/applications/j14kfikjjif185likj5wnuay -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44')", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('name:', d.get\\('name'\\)\\); print\\('status:', d.get\\('status'\\)\\); print\\('build_pack:', d.get\\('build_pack'\\)\\); print\\('git_repo:', d.get\\('git_repository'\\)\\)\")", + "Bash(curl -s https://coolify.nicolasfryder.ovh/api/v1/applications/j14kfikjjif185likj5wnuay -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44')", + "Bash(python3 -c ' *)", + "Bash(curl -s https://coolify.nicolasfryder.ovh/api/v1/applications/j14kfikjjif185likj5wnuay -H 'Authorization: Bearer 4|c1184175c459eea1f84bcd80005e6735e2e56a44' -o /tmp/app_resp.json)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k:d[k] for k in ['name','status','build_pack','webhook','git_repository','docker_compose_location'] if k in d}, indent=2\\)\\)\")", + "Bash(git commit *)", + "Bash(git rm *)", + "Bash(rm -rf \"infra/overrides\")" + ] + } +} diff --git a/apps/api/src/server.js b/apps/api/src/server.js index 72c88a0..3f3595f 100644 --- a/apps/api/src/server.js +++ b/apps/api/src/server.js @@ -1,17 +1,67 @@ import Fastify from "fastify"; import pg from "pg"; -import crypto from "crypto"; +import rateLimit from "@fastify/rate-limit"; +import helmet from "@fastify/helmet"; +import { timingSafeEqual } from "crypto"; const { Pool } = pg; -const fastify = Fastify({ logger: true }); +const fastify = Fastify({ + logger: true, + bodyLimit: 10485760 // 10 MB max pour éviter DoS mémoire +}); + +// Headers de sécurité +await fastify.register(helmet, { + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false +}); + +// CORS middleware +fastify.addHook('onRequest', async (request, reply) => { + const origin = request.headers.origin; + const allowedOrigins = ['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 }); +if (DATABASE_URL) { + pool = new Pool({ + connectionString: DATABASE_URL, + statement_timeout: 60000 + }); +} function requirePool() { if (!pool) throw new Error("DATABASE_URL not set"); @@ -25,473 +75,644 @@ function authBearer(req) { } function requireAdmin(req, reply) { - const t = authBearer(req); - if (!BM_IMPORT_TOKEN || !t || t !== BM_IMPORT_TOKEN) { + 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 normStatus(s) { - const v = String(s || "").replace(/\s+/g, " ").trim(); - return v || "Unknown"; +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 ` - - - -BM API - - - -

BM backend ✅

- -

Database: ${DATABASE_URL ? "configured" : "NOT SET"}

- -`; + return { ok: true, service: "BM API" }; }); fastify.get("/api/health", async () => ({ ok: true })); -fastify.get("/api/db", async () => { - const p = requirePool(); - const r = await p.query("select now() as now, current_database() as db"); - return { ok: true, ...r.rows[0] }; + +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' }); + } }); -/** - * Stats globales (utile pour UI) - */ -fastify.get("/api/stats", async () => { - 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] }; -}); - -/** - * Liste des pays + counts (pour multi-select UI) - */ -fastify.get("/api/countries", async () => { - 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 }; -}); - -/** - * Statuts + counts (filtre UI) - */ -fastify.get("/api/statuses", async () => { - 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 }; -}); - -/** - * CLUSTERS ENDPOINT - Viewport-based clustering for performance - * - * Query params: - * - bbox=minLon,minLat,maxLon,maxLat (required) - viewport bounds - * - zoom=N (required) - current zoom level - * - countries=FR,DE,IT (optional) - * - status=Active,Split-up (optional) - * - genre=black (optional, ILIKE) - * - year_min, year_max (optional) - * - * Returns clusters with count and representative bands - */ -fastify.get("/api/clusters", async (req) => { - const p = requirePool(); - const { - bbox, - zoom, - countries, - status, - genre, - year_min, - year_max, - } = req.query || {}; - - if (!bbox || !zoom) { - return { ok: false, error: "bbox and zoom are required" }; - } - - const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number); - const zoomLevel = Number(zoom); - - if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { - return { ok: false, error: "Invalid bbox or zoom values" }; - } - - // Calculate grid cell size based on zoom level - // Higher zoom = smaller cells = more detail - // At zoom 4 (continental) ~2 degrees, at zoom 10 (city) ~0.05 degrees - const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel)); - - const where = ["geom IS NOT NULL"]; - const vals = []; - let i = 1; - - // Bounding box filter using PostGIS - where.push(`ST_X(geom) >= $${i} AND ST_X(geom) <= $${i+1} AND ST_Y(geom) >= $${i+2} AND ST_Y(geom) <= $${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) { - 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) { - where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); - vals.push(st); - i++; - } - } - - if (genre) { - where.push(`genre ILIKE $${i}`); - vals.push(`%${String(genre).trim()}%`); - i++; - } - - if (year_min) { - where.push(`formed_year >= $${i}`); - vals.push(Number(year_min)); - i++; - } - - if (year_max) { - where.push(`formed_year <= $${i}`); - vals.push(Number(year_max)); - i++; - } - - const whereSql = where.join(" AND "); - - // For high zoom levels (>= 12), return individual bands - if (zoomLevel >= 12) { - const sql = ` - SELECT - ma_id, - name, - url, - country, - COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, - genre, - location_text, - formed_year, - ST_Y(geom)::float8 as lat, - ST_X(geom)::float8 as lon - FROM bands - WHERE ${whereSql} - ORDER BY ma_id ASC - LIMIT 2000; +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(sql, vals); - return { ok: true, type: "bands", items: r.rows, count: r.rows.length }; + 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' }); } +}); - // For lower zoom levels, return aggregated clusters - const sql = ` - WITH grid_cells AS ( - SELECT - floor(ST_X(geom) / $${i})::int as cell_x, - floor(ST_Y(geom) / $${i})::int as cell_y, +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, - url, country, COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, genre, location_text, + enriched, formed_year, - ST_Y(geom)::float8 as lat, - ST_X(geom)::float8 as lon + themes, + 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, - 'url', url, - '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; - `; + ${whereSql} + ORDER BY ma_id ASC + LIMIT $${i} OFFSET $${i+1}; + `; + vals.push(lim, off); - 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 - }; + 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' }); + } }); -/** - * Bands (flux principal UI) - LEGACY endpoint, kept for compatibility - * For better performance, use /api/clusters with viewport - */ -fastify.get("/api/bands", async (req) => { - const p = requirePool(); - const { - countries, - geocoded, - q, - status, - only_black, - limit, - offset, - } = req.query || {}; +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()) + `) + ]); - const lim = Math.min(Number(limit || 20000), 50000); - const off = Math.max(Number(offset || 0), 0); - - const where = []; - const vals = []; - let i = 1; - - if (countries) { - const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); - if (cs.length) { - where.push(`country = ANY($${i}::text[])`); - vals.push(cs); - i++; - } + 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' }); } - - 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) { - 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) { - const qq = `%${String(q).trim()}%`; - where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`); - vals.push(qq); - i++; - } - - const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; - const sql = ` - SELECT - ma_id, - name, - url, - country, - COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, - genre, - location_text, - enriched, - formed_year, - themes, - CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat, - CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon - FROM bands - ${whereSql} - ORDER BY ma_id ASC - LIMIT ${lim} OFFSET ${off}; - `; - const r = await p.query(sql, vals); - return { ok: true, items: r.rows, limit: lim, offset: off }; }); -/** - * Get minimal data for initial load (facets only, no band details) - * Used to populate filters without loading all bands - */ -fastify.get("/api/facets", async () => { - const p = requirePool(); - - // Get all facet data in parallel - 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 } - }; -}); - -/** - * Détail d'un groupe (pour modal "fiche") - */ fastify.get("/api/band/:ma_id", async (req, reply) => { - const p = requirePool(); - const id = Number(req.params.ma_id); - if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad ma_id" }); + 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, url, country, status, genre, location_text, enriched, data, - formed_year, themes, - CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat, - CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as 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] }; -}); - -/** - * ADMIN import - */ -fastify.post("/admin/import", async (req, reply) => { - if (!requireAdmin(req, reply)) return; - - 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[]" }); - - 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 url = b.url ?? (b.data?.url ?? 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, url, country, status, genre, location_text, data, enriched) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9,false)) - ON CONFLICT (ma_id) DO UPDATE SET - name = COALESCE(EXCLUDED.name, bands.name), - url = COALESCE(EXCLUDED.url, bands.url), - 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, url, country, status, genre, location_text, data, enriched] + 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] ); - upserted++; - } - return reply.send({ ok: true, upserted }); + 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.listen({ port: PORT, host: "0.0.0.0" }); +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' }); + } + }); +}); + +fastify.listen({ port: PORT, host: "0.0.0.0" }); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 63b975f..f86fb56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,8 +51,6 @@ services: PORT: "3000" DATABASE_URL: ${DATABASE_URL} BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} - volumes: - - ./infra/overrides/api/src/server.js:/app/src/server.js:ro networks: - bm_internal - coolify diff --git a/infra/overrides/api/src/server.js b/infra/overrides/api/src/server.js deleted file mode 100644 index 3f3595f..0000000 --- a/infra/overrides/api/src/server.js +++ /dev/null @@ -1,718 +0,0 @@ -import Fastify from "fastify"; -import pg from "pg"; -import rateLimit from "@fastify/rate-limit"; -import helmet from "@fastify/helmet"; -import { timingSafeEqual } from "crypto"; - -const { Pool } = pg; - -const fastify = Fastify({ - logger: true, - bodyLimit: 10485760 // 10 MB max pour éviter DoS mémoire -}); - -// Headers de sécurité -await fastify.register(helmet, { - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false -}); - -// CORS middleware -fastify.addHook('onRequest', async (request, reply) => { - const origin = request.headers.origin; - const allowedOrigins = ['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 - }); -} - -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' }); - } - }); -}); - -fastify.listen({ port: PORT, host: "0.0.0.0" }); \ No newline at end of file diff --git a/infra/overrides/api/src/server_a.js b/infra/overrides/api/src/server_a.js deleted file mode 100644 index 783ea71..0000000 --- a/infra/overrides/api/src/server_a.js +++ /dev/null @@ -1,521 +0,0 @@ -import Fastify from "fastify"; -import pg from "pg"; - -const { Pool } = pg; - -const fastify = Fastify({ logger: true }); - -// CORS middleware -fastify.addHook('onRequest', async (request, reply) => { - const origin = request.headers.origin; - const allowedOrigins = ['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'); - - // Handle preflight - if (request.method === 'OPTIONS') { - reply.status(204).send(); - } -}); - -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 }); - -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 t = authBearer(req); - if (!BM_IMPORT_TOKEN || !t || t !== BM_IMPORT_TOKEN) { - reply.code(401).send({ ok: false, error: "unauthorized" }); - return false; - } - return true; -} - -fastify.get("/", async () => { - return ` - - - -BM API - - - -

BM backend ✅

- -

Database: ${DATABASE_URL ? "configured" : "NOT SET"}

- -`; -}); - -fastify.get("/api/health", async () => ({ ok: true })); -fastify.get("/api/db", async () => { - const p = requirePool(); - const r = await p.query("select now() as now, current_database() as db"); - return { ok: true, ...r.rows[0] }; -}); - -/** - * Stats globales (utile pour UI) - */ -fastify.get("/api/stats", async () => { - 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] }; -}); - -/** - * Liste des pays + counts (pour multi-select UI) - */ -fastify.get("/api/countries", async () => { - 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 }; -}); - -/** - * Statuts + counts (filtre UI) - */ -fastify.get("/api/statuses", async () => { - 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 }; -}); - -/** - * CLUSTERS ENDPOINT - Viewport-based clustering for performance - */ -fastify.get("/api/clusters", async (req) => { - const p = requirePool(); - const { - bbox, - zoom, - countries, - status, - genre, - year_min, - year_max, - } = req.query || {}; - - if (!bbox || !zoom) { - return { ok: false, error: "bbox and zoom are required" }; - } - - const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number); - const zoomLevel = Number(zoom); - - if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { - return { ok: false, error: "Invalid bbox or zoom values" }; - } - - 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) { - 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) { - where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); - vals.push(st); - i++; - } - } - - if (genre) { - where.push(`genre ILIKE $${i}`); - vals.push(`%${String(genre).trim()}%`); - i++; - } - - if (year_min) { - where.push(`formed_year >= $${i}`); - vals.push(Number(year_min)); - i++; - } - - if (year_max) { - where.push(`formed_year <= $${i}`); - vals.push(Number(year_max)); - i++; - } - - const whereSql = where.join(" AND "); - - // For high zoom levels (>= 12), return individual bands - 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 }; - } - - // For lower zoom levels, return aggregated clusters - 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 - }; -}); - -/** - * Bands (flux principal UI) - LEGACY endpoint - */ -fastify.get("/api/bands", async (req) => { - const p = requirePool(); - const { - countries, - geocoded, - q, - status, - only_black, - limit, - offset, - } = req.query || {}; - - const lim = Math.min(Number(limit || 20000), 150000); - const off = Math.max(Number(offset || 0), 0); - - const where = []; - const vals = []; - let i = 1; - - if (countries) { - const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); - 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) { - 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) { - const qq = `%${String(q).trim()}%`; - where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`); - vals.push(qq); - i++; - } - - 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 ${lim} OFFSET ${off}; - `; - const r = await p.query(sql, vals); - return { ok: true, items: r.rows, limit: lim, offset: off }; -}); - -/** - * Get minimal data for initial load (facets only) - */ -fastify.get("/api/facets", async () => { - 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 } - }; -}); - -/** - * Détail d'un groupe (pour modal "fiche") - */ -fastify.get("/api/band/:ma_id", async (req, reply) => { - const p = requirePool(); - const id = Number(req.params.ma_id); - if (!Number.isFinite(id)) 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] }; -}); - -/** - * ADMIN import - */ -fastify.post("/admin/import", async (req, reply) => { - if (!requireAdmin(req, reply)) return; - - 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[]" }); - - 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 }); -}); - -/** - * GET /admin/enrich/next?limit=50&country=FR - * Renvoie les bands dont data.band_page est absent (non enrichis). - */ -fastify.get("/admin/enrich/next", async (req, reply) => { - if (!requireAdmin(req, reply)) return; - 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 }; -}); - -fastify.listen({ port: PORT, host: "0.0.0.0" });