feat: integrer override api en production (supprimer mecanisme override)
This commit is contained in:
parent
68dfdf10c2
commit
d241d168e3
5 changed files with 719 additions and 1676 deletions
63
.claude/settings.local.json
Normal file
63
.claude/settings.local.json
Normal file
|
|
@ -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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,67 @@
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import pg from "pg";
|
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 { 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 PORT = Number(process.env.PORT || 3000);
|
||||||
const DATABASE_URL = process.env.DATABASE_URL;
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim();
|
const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim();
|
||||||
|
|
||||||
let pool = null;
|
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() {
|
function requirePool() {
|
||||||
if (!pool) throw new Error("DATABASE_URL not set");
|
if (!pool) throw new Error("DATABASE_URL not set");
|
||||||
|
|
@ -25,53 +75,63 @@ function authBearer(req) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireAdmin(req, reply) {
|
function requireAdmin(req, reply) {
|
||||||
const t = authBearer(req);
|
const provided = authBearer(req);
|
||||||
if (!BM_IMPORT_TOKEN || !t || t !== BM_IMPORT_TOKEN) {
|
|
||||||
|
if (!BM_IMPORT_TOKEN || !provided || provided.length !== BM_IMPORT_TOKEN.length) {
|
||||||
reply.code(401).send({ ok: false, error: "unauthorized" });
|
reply.code(401).send({ ok: false, error: "unauthorized" });
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normStatus(s) {
|
function sanitizeSearchString(str, fieldName = 'champ') {
|
||||||
const v = String(s || "").replace(/\s+/g, " ").trim();
|
const trimmed = String(str).trim();
|
||||||
return v || "Unknown";
|
|
||||||
|
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 () => {
|
fastify.get("/", async () => {
|
||||||
return `
|
return { ok: true, service: "BM API" };
|
||||||
<!doctype html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>BM API</title>
|
|
||||||
<style>body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;padding:24px;max-width:920px;margin:auto}code{background:#f2f2f2;padding:2px 6px;border-radius:6px}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>BM backend ✅</h1>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/api/health">/api/health</a></li>
|
|
||||||
<li><a href="/api/db">/api/db</a></li>
|
|
||||||
<li><a href="/api/stats">/api/stats</a></li>
|
|
||||||
<li><a href="/api/countries">/api/countries</a></li>
|
|
||||||
<li><a href="/api/statuses">/api/statuses</a></li>
|
|
||||||
<li><a href="/api/clusters">/api/clusters</a> (viewport clustering)</li>
|
|
||||||
</ul>
|
|
||||||
<p>Database: <code>${DATABASE_URL ? "configured" : "NOT SET"}</code></p>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/api/health", async () => ({ ok: true }));
|
fastify.get("/api/health", async () => ({ ok: true }));
|
||||||
fastify.get("/api/db", async () => {
|
|
||||||
|
fastify.get("/api/db", async (req, reply) => {
|
||||||
|
try {
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const r = await p.query("select now() as now, current_database() as db");
|
const r = await p.query("SELECT now() as now, current_database() as db");
|
||||||
return { ok: true, ...r.rows[0] };
|
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) => {
|
||||||
* Stats globales (utile pour UI)
|
try {
|
||||||
*/
|
|
||||||
fastify.get("/api/stats", async () => {
|
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const q = `
|
const q = `
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -83,12 +143,14 @@ fastify.get("/api/stats", async () => {
|
||||||
`;
|
`;
|
||||||
const r = await p.query(q);
|
const r = await p.query(q);
|
||||||
return { ok: true, ...r.rows[0] };
|
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) => {
|
||||||
* Liste des pays + counts (pour multi-select UI)
|
try {
|
||||||
*/
|
|
||||||
fastify.get("/api/countries", async () => {
|
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const q = `
|
const q = `
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -102,12 +164,14 @@ fastify.get("/api/countries", async () => {
|
||||||
`;
|
`;
|
||||||
const r = await p.query(q);
|
const r = await p.query(q);
|
||||||
return { ok: true, items: r.rows };
|
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) => {
|
||||||
* Statuts + counts (filtre UI)
|
try {
|
||||||
*/
|
|
||||||
fastify.get("/api/statuses", async () => {
|
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const q = `
|
const q = `
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -120,22 +184,14 @@ fastify.get("/api/statuses", async () => {
|
||||||
`;
|
`;
|
||||||
const r = await p.query(q);
|
const r = await p.query(q);
|
||||||
return { ok: true, items: r.rows };
|
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) => {
|
||||||
* CLUSTERS ENDPOINT - Viewport-based clustering for performance
|
try {
|
||||||
*
|
|
||||||
* 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 p = requirePool();
|
||||||
const {
|
const {
|
||||||
bbox,
|
bbox,
|
||||||
|
|
@ -148,32 +204,48 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
} = req.query || {};
|
} = req.query || {};
|
||||||
|
|
||||||
if (!bbox || !zoom) {
|
if (!bbox || !zoom) {
|
||||||
return { ok: false, error: "bbox and zoom are required" };
|
return reply.code(400).send({ ok: false, error: "bbox and zoom are required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number);
|
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);
|
const zoomLevel = Number(zoom);
|
||||||
|
|
||||||
if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
|
if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
|
||||||
return { ok: false, error: "Invalid bbox or zoom values" };
|
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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
|
||||||
|
|
||||||
const where = ["geom IS NOT NULL"];
|
const where = ["geom IS NOT NULL"];
|
||||||
const vals = [];
|
const vals = [];
|
||||||
let i = 1;
|
let i = 1;
|
||||||
|
|
||||||
// Bounding box filter using PostGIS
|
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}`);
|
||||||
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);
|
vals.push(minLon, maxLon, minLat, maxLat);
|
||||||
i += 4;
|
i += 4;
|
||||||
|
|
||||||
if (countries) {
|
if (countries) {
|
||||||
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
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) {
|
if (cs.length) {
|
||||||
where.push(`country = ANY($${i}::text[])`);
|
where.push(`country = ANY($${i}::text[])`);
|
||||||
vals.push(cs);
|
vals.push(cs);
|
||||||
|
|
@ -183,6 +255,9 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
|
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) {
|
if (st.length) {
|
||||||
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
||||||
vals.push(st);
|
vals.push(st);
|
||||||
|
|
@ -191,64 +266,78 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genre) {
|
if (genre) {
|
||||||
|
try {
|
||||||
|
const genreQuery = sanitizeSearchString(genre, 'Genre');
|
||||||
where.push(`genre ILIKE $${i}`);
|
where.push(`genre ILIKE $${i}`);
|
||||||
vals.push(`%${String(genre).trim()}%`);
|
vals.push(`%${genreQuery}%`);
|
||||||
i++;
|
i++;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (year_min) {
|
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}`);
|
where.push(`formed_year >= $${i}`);
|
||||||
vals.push(Number(year_min));
|
vals.push(yearMin);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (year_max) {
|
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}`);
|
where.push(`formed_year <= $${i}`);
|
||||||
vals.push(Number(year_max));
|
vals.push(yearMax);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSql = where.join(" AND ");
|
const whereSql = where.join(" AND ");
|
||||||
|
|
||||||
// For high zoom levels (>= 12), return individual bands
|
|
||||||
if (zoomLevel >= 12) {
|
if (zoomLevel >= 12) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
ma_id,
|
ma_id,
|
||||||
name,
|
name,
|
||||||
url,
|
|
||||||
country,
|
country,
|
||||||
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
genre,
|
genre,
|
||||||
location_text,
|
location_text,
|
||||||
formed_year,
|
formed_year,
|
||||||
ST_Y(geom)::float8 as lat,
|
lat,
|
||||||
ST_X(geom)::float8 as lon
|
lon
|
||||||
FROM bands
|
FROM bands
|
||||||
WHERE ${whereSql}
|
WHERE ${whereSql}
|
||||||
ORDER BY ma_id ASC
|
ORDER BY ma_id ASC
|
||||||
LIMIT 2000;
|
LIMIT 2000;
|
||||||
`;
|
`;
|
||||||
const r = await p.query(sql, vals);
|
const r = await p.query(sql, vals);
|
||||||
return { ok: true, type: "bands", items: r.rows, count: r.rows.length };
|
return {
|
||||||
|
ok: true,
|
||||||
|
type: "bands",
|
||||||
|
items: r.rows,
|
||||||
|
count: r.rows.length
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For lower zoom levels, return aggregated clusters
|
|
||||||
const sql = `
|
const sql = `
|
||||||
WITH grid_cells AS (
|
WITH grid_cells AS (
|
||||||
SELECT
|
SELECT
|
||||||
floor(ST_X(geom) / $${i})::int as cell_x,
|
floor(ST_X(geom::geometry) / $${i})::int as cell_x,
|
||||||
floor(ST_Y(geom) / $${i})::int as cell_y,
|
floor(ST_Y(geom::geometry) / $${i})::int as cell_y,
|
||||||
ma_id,
|
ma_id,
|
||||||
name,
|
name,
|
||||||
url,
|
|
||||||
country,
|
country,
|
||||||
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
genre,
|
genre,
|
||||||
location_text,
|
location_text,
|
||||||
formed_year,
|
formed_year,
|
||||||
ST_Y(geom)::float8 as lat,
|
lat,
|
||||||
ST_X(geom)::float8 as lon
|
lon
|
||||||
FROM bands
|
FROM bands
|
||||||
WHERE ${whereSql}
|
WHERE ${whereSql}
|
||||||
),
|
),
|
||||||
|
|
@ -262,7 +351,6 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
array_agg(json_build_object(
|
array_agg(json_build_object(
|
||||||
'ma_id', ma_id,
|
'ma_id', ma_id,
|
||||||
'name', name,
|
'name', name,
|
||||||
'url', url,
|
|
||||||
'country', country,
|
'country', country,
|
||||||
'status', status,
|
'status', status,
|
||||||
'genre', genre,
|
'genre', genre,
|
||||||
|
|
@ -286,10 +374,9 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
ORDER BY band_count DESC
|
ORDER BY band_count DESC
|
||||||
LIMIT 1000;
|
LIMIT 1000;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
vals.push(cellSize);
|
vals.push(cellSize);
|
||||||
const r = await p.query(sql, vals);
|
|
||||||
|
|
||||||
|
const r = await p.query(sql, vals);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
type: "clusters",
|
type: "clusters",
|
||||||
|
|
@ -298,13 +385,14 @@ fastify.get("/api/clusters", async (req) => {
|
||||||
cell_size: cellSize,
|
cell_size: cellSize,
|
||||||
zoom: zoomLevel
|
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) => {
|
||||||
* Bands (flux principal UI) - LEGACY endpoint, kept for compatibility
|
try {
|
||||||
* For better performance, use /api/clusters with viewport
|
|
||||||
*/
|
|
||||||
fastify.get("/api/bands", async (req) => {
|
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const {
|
const {
|
||||||
countries,
|
countries,
|
||||||
|
|
@ -316,15 +404,29 @@ fastify.get("/api/bands", async (req) => {
|
||||||
offset,
|
offset,
|
||||||
} = req.query || {};
|
} = req.query || {};
|
||||||
|
|
||||||
const lim = Math.min(Number(limit || 20000), 50000);
|
const lim = Math.min(Number(limit || 1000), 150000);
|
||||||
const off = Math.max(Number(offset || 0), 0);
|
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 where = [];
|
||||||
const vals = [];
|
const vals = [];
|
||||||
let i = 1;
|
let i = 1;
|
||||||
|
|
||||||
if (countries) {
|
if (countries) {
|
||||||
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
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) {
|
if (cs.length) {
|
||||||
where.push(`country = ANY($${i}::text[])`);
|
where.push(`country = ANY($${i}::text[])`);
|
||||||
vals.push(cs);
|
vals.push(cs);
|
||||||
|
|
@ -337,6 +439,9 @@ fastify.get("/api/bands", async (req) => {
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
|
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) {
|
if (st.length) {
|
||||||
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
||||||
vals.push(st);
|
vals.push(st);
|
||||||
|
|
@ -349,18 +454,23 @@ fastify.get("/api/bands", async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const qq = `%${String(q).trim()}%`;
|
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})`);
|
where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`);
|
||||||
vals.push(qq);
|
vals.push(qq);
|
||||||
i++;
|
i++;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
ma_id,
|
ma_id,
|
||||||
name,
|
name,
|
||||||
url,
|
|
||||||
country,
|
country,
|
||||||
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
genre,
|
genre,
|
||||||
|
|
@ -368,45 +478,71 @@ fastify.get("/api/bands", async (req) => {
|
||||||
enriched,
|
enriched,
|
||||||
formed_year,
|
formed_year,
|
||||||
themes,
|
themes,
|
||||||
CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat,
|
lat,
|
||||||
CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon
|
lon
|
||||||
FROM bands
|
FROM bands
|
||||||
${whereSql}
|
${whereSql}
|
||||||
ORDER BY ma_id ASC
|
ORDER BY ma_id ASC
|
||||||
LIMIT ${lim} OFFSET ${off};
|
LIMIT $${i} OFFSET $${i+1};
|
||||||
`;
|
`;
|
||||||
|
vals.push(lim, off);
|
||||||
|
|
||||||
const r = await p.query(sql, vals);
|
const r = await p.query(sql, vals);
|
||||||
return { ok: true, items: r.rows, limit: lim, offset: off };
|
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) => {
|
||||||
* Get minimal data for initial load (facets only, no band details)
|
try {
|
||||||
* Used to populate filters without loading all bands
|
|
||||||
*/
|
|
||||||
fastify.get("/api/facets", async () => {
|
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
|
|
||||||
// Get all facet data in parallel
|
|
||||||
const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
|
const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
|
||||||
p.query(`
|
p.query(`
|
||||||
SELECT COALESCE(NULLIF(trim(status), ''), 'Unknown') as value, count(*)::int as count
|
SELECT
|
||||||
FROM bands WHERE geom IS NOT NULL
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as value,
|
||||||
GROUP BY 1 ORDER BY count DESC
|
count(*)::int as count
|
||||||
|
FROM bands
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY count DESC
|
||||||
`),
|
`),
|
||||||
p.query(`
|
p.query(`
|
||||||
SELECT COALESCE(country, '??') as value, count(*)::int as count
|
SELECT
|
||||||
FROM bands WHERE geom IS NOT NULL
|
COALESCE(country, '??') as value,
|
||||||
GROUP BY 1 ORDER BY count DESC
|
count(*)::int as count
|
||||||
|
FROM bands
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY count DESC
|
||||||
`),
|
`),
|
||||||
p.query(`
|
p.query(`
|
||||||
SELECT genre as value, count(*)::int as count
|
SELECT
|
||||||
FROM bands WHERE geom IS NOT NULL AND genre IS NOT NULL AND genre != ''
|
genre as value,
|
||||||
GROUP BY 1 ORDER BY count DESC
|
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
|
LIMIT 500
|
||||||
`),
|
`),
|
||||||
p.query(`
|
p.query(`
|
||||||
SELECT MIN(formed_year)::int as min_year, MAX(formed_year)::int as max_year
|
SELECT
|
||||||
FROM bands WHERE geom IS NOT NULL AND formed_year >= 1900 AND formed_year <= extract(year from now())
|
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())
|
||||||
`)
|
`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -417,68 +553,109 @@ fastify.get("/api/facets", async () => {
|
||||||
genres: genreRes.rows,
|
genres: genreRes.rows,
|
||||||
year_range: yearRes.rows[0] || { min_year: null, max_year: null }
|
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' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Détail d'un groupe (pour modal "fiche")
|
|
||||||
*/
|
|
||||||
fastify.get("/api/band/:ma_id", async (req, reply) => {
|
fastify.get("/api/band/:ma_id", async (req, reply) => {
|
||||||
|
try {
|
||||||
const p = requirePool();
|
const p = requirePool();
|
||||||
const id = Number(req.params.ma_id);
|
const id = Number(req.params.ma_id);
|
||||||
if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad ma_id" });
|
|
||||||
|
if (!Number.isFinite(id) || id < 0) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "bad ma_id" });
|
||||||
|
}
|
||||||
|
|
||||||
const r = await p.query(
|
const r = await p.query(
|
||||||
`SELECT ma_id, name, url, country, status, genre, location_text, enriched, data,
|
`SELECT
|
||||||
formed_year, themes,
|
ma_id,
|
||||||
CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat,
|
name,
|
||||||
CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon,
|
country,
|
||||||
|
status,
|
||||||
|
genre,
|
||||||
|
location_text,
|
||||||
|
enriched,
|
||||||
|
data,
|
||||||
|
formed_year,
|
||||||
|
themes,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
geocoded_at
|
geocoded_at
|
||||||
FROM bands
|
FROM bands
|
||||||
WHERE ma_id = $1
|
WHERE ma_id = $1
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
|
|
||||||
|
if (!r.rows.length) {
|
||||||
|
return reply.code(404).send({ ok: false, error: "not found" });
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, item: r.rows[0] };
|
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) {
|
||||||
* ADMIN import
|
await adminRoutes.register(rateLimit, {
|
||||||
*/
|
max: 10,
|
||||||
fastify.post("/admin/import", async (req, reply) => {
|
timeWindow: '1 minute',
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
return authBearer(req) || req.ip;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post("/admin/import", async (req, reply) => {
|
||||||
if (!requireAdmin(req, reply)) return;
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
if (!body || typeof body !== "object") return reply.code(400).send({ ok: false, error: "invalid body/json" });
|
|
||||||
|
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;
|
const bands = Array.isArray(body.bands) ? body.bands : null;
|
||||||
if (!bands) return reply.code(400).send({ ok: false, error: "missing bands[]" });
|
|
||||||
|
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();
|
const p = requirePool();
|
||||||
|
|
||||||
let upserted = 0;
|
let upserted = 0;
|
||||||
|
|
||||||
for (const b of bands) {
|
for (const b of bands) {
|
||||||
if (!b || typeof b !== "object") continue;
|
if (!b || typeof b !== "object") continue;
|
||||||
|
|
||||||
const ma_id = Number(b.ma_id);
|
const ma_id = Number(b.ma_id);
|
||||||
if (!Number.isFinite(ma_id)) continue;
|
if (!Number.isFinite(ma_id)) continue;
|
||||||
|
|
||||||
const name = b.name ?? null;
|
const name = b.name ?? null;
|
||||||
const url = b.url ?? (b.data?.url ?? null);
|
|
||||||
const country = b.country ?? null;
|
const country = b.country ?? null;
|
||||||
const status = b.status ?? null;
|
const status = b.status ?? null;
|
||||||
const genre = b.genre ?? null;
|
const genre = b.genre ?? null;
|
||||||
const location_text = b.location_text ?? b.location ?? null;
|
const location_text = b.location_text ?? b.location ?? null;
|
||||||
|
|
||||||
const hasData = b.data && typeof b.data === "object";
|
const hasData = b.data && typeof b.data === "object";
|
||||||
const data = hasData ? b.data : null;
|
const data = hasData ? b.data : null;
|
||||||
const enriched = hasData ? true : (b.enriched ?? null);
|
const enriched = hasData ? true : (b.enriched ?? null);
|
||||||
|
|
||||||
await p.query(
|
await p.query(
|
||||||
`
|
`
|
||||||
INSERT INTO bands (ma_id, name, url, country, status, genre, location_text, data, enriched)
|
INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9,false))
|
VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false))
|
||||||
ON CONFLICT (ma_id) DO UPDATE SET
|
ON CONFLICT (ma_id) DO UPDATE SET
|
||||||
name = COALESCE(EXCLUDED.name, bands.name),
|
name = COALESCE(EXCLUDED.name, bands.name),
|
||||||
url = COALESCE(EXCLUDED.url, bands.url),
|
|
||||||
country = COALESCE(EXCLUDED.country, bands.country),
|
country = COALESCE(EXCLUDED.country, bands.country),
|
||||||
status = COALESCE(EXCLUDED.status, bands.status),
|
status = COALESCE(EXCLUDED.status, bands.status),
|
||||||
genre = COALESCE(EXCLUDED.genre, bands.genre),
|
genre = COALESCE(EXCLUDED.genre, bands.genre),
|
||||||
|
|
@ -486,12 +663,56 @@ fastify.post("/admin/import", async (req, reply) => {
|
||||||
data = COALESCE(EXCLUDED.data, bands.data),
|
data = COALESCE(EXCLUDED.data, bands.data),
|
||||||
enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
|
enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
|
||||||
`,
|
`,
|
||||||
[ma_id, name, url, country, status, genre, location_text, data, enriched]
|
[ma_id, name, country, status, genre, location_text, data, enriched]
|
||||||
);
|
);
|
||||||
upserted++;
|
upserted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.send({ ok: true, 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" });
|
fastify.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
|
|
@ -51,8 +51,6 @@ services:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
||||||
volumes:
|
|
||||||
- ./infra/overrides/api/src/server.js:/app/src/server.js:ro
|
|
||||||
networks:
|
networks:
|
||||||
- bm_internal
|
- bm_internal
|
||||||
- coolify
|
- coolify
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
|
||||||
|
|
@ -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 `
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>BM API</title>
|
|
||||||
<style>body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;padding:24px;max-width:920px;margin:auto}code{background:#f2f2f2;padding:2px 6px;border-radius:6px}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>BM backend ✅</h1>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/api/health">/api/health</a></li>
|
|
||||||
<li><a href="/api/db">/api/db</a></li>
|
|
||||||
<li><a href="/api/stats">/api/stats</a></li>
|
|
||||||
<li><a href="/api/countries">/api/countries</a></li>
|
|
||||||
<li><a href="/api/statuses">/api/statuses</a></li>
|
|
||||||
<li><a href="/api/facets">/api/facets</a></li>
|
|
||||||
<li><a href="/api/clusters">/api/clusters</a> (viewport clustering)</li>
|
|
||||||
</ul>
|
|
||||||
<p>Database: <code>${DATABASE_URL ? "configured" : "NOT SET"}</code></p>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
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" });
|
|
||||||
Loading…
Reference in a new issue