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" });