Le dépôt n'avait aucun test, aucun linter, aucune vérification de types. Outillage - ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python - tsc --checkJs sur l'API (pas de TypeScript, juste la vérification) - Vitest : 401 tests JS ; pytest : 43 tests Python - Tests de mutation (Stryker), deux profils : logique pure et API complète - Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook, c'est donc la seule porte de qualité avant la mise en ligne - Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré) Sécurité - Injection SQL authentifiée dans resolve-conflict : `field` était interpolé dans le SET sans allowlist - timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401) - setErrorHandler écrasait tous les 4xx en 500 - .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part alors que leur absence casse toute connexion admin Annulation réelle des crawl_run (migration 014) - L'API posait status='error' sans que le crawler en sache rien : le process continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur) - Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler écrit lui-même status='cancelled' Cohérence géographique (migration 014) - Le trigger 013 supprimait les band_locations sans purger le point dénormalisé - L'édition admin de lat/lon n'atteignait jamais band_locations : la carte ignorait la correction. Override step_order = -1, dans une transaction Corrections - limit/offset NaN → 500 au lieu de 400 - OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie) - listen() sans catch, cast ::text en dur sur les colonnes numériques - /admin/api/logs ne renvoyait pas sa pagination - a11y : sélecteur de langue annoncé comme liste vide (role=option manquant) Nettoyage - apps/web/quizz-site supprimé (sans rapport avec le projet) - Code mort : openModal(), LANG_NAMES, double import, variables inutilisées - .dockerignore ajoutés ; node_modules racine n'était pas gitignoré Co-Authored-By: Claude <noreply@anthropic.com>
843 lines
26 KiB
JavaScript
843 lines
26 KiB
JavaScript
/**
|
|
* Construction de l'application Fastify.
|
|
*
|
|
* Séparé de server.js (qui se contente d'écouter) pour que les tests puissent
|
|
* instancier l'app avec un faux pool et l'interroger via `fastify.inject()`,
|
|
* sans ouvrir de port ni de connexion Postgres.
|
|
*/
|
|
import Fastify from "fastify";
|
|
import rateLimit from "@fastify/rate-limit";
|
|
import helmet from "@fastify/helmet";
|
|
import cookie from "@fastify/cookie";
|
|
import { timingSafeEqual } from "crypto";
|
|
import {
|
|
ADMIN_COOKIE_NAME,
|
|
seedAdminUser,
|
|
signAdminSession,
|
|
isLockedOut,
|
|
recordLoginAttempt,
|
|
verifyPassword,
|
|
markLoginSuccess,
|
|
requireAdminSession,
|
|
} from "./adminAuth.js";
|
|
import adminApiRoutes from "./adminRoutes.js";
|
|
import { ValidationError, sanitizeSearchString, parseLimitOffset } from "./validate.js";
|
|
|
|
/**
|
|
* @param {object} [opts]
|
|
* @param {object|null} [opts.pool] pool pg (ou un double de test)
|
|
* @param {boolean} [opts.logger]
|
|
* @param {string} [opts.importToken] jeton Bearer de /admin/import
|
|
* @param {string[]} [opts.corsOrigins]
|
|
* @param {boolean} [opts.seedAdmin] false en test (évite un INSERT parasite)
|
|
* @param {number} [opts.globalRateLimitMax] requêtes/min, toutes routes
|
|
* @param {number} [opts.adminRateLimitMax] requêtes/min sur /admin/import
|
|
* @param {number} [opts.authRateLimitMax] requêtes/min sur /admin/auth/login
|
|
*/
|
|
export async function buildServer(opts = {}) {
|
|
const {
|
|
pool = null,
|
|
logger = false,
|
|
importToken = (process.env.BM_IMPORT_TOKEN || "").trim(),
|
|
corsOrigins = process.env.CORS_ORIGINS
|
|
? process.env.CORS_ORIGINS.split(",").map((s) => s.trim())
|
|
: ["https://metalfrom.eu", "https://www.metalfrom.eu"],
|
|
seedAdmin = true,
|
|
// Plafonds de débit paramétrables : les tests partagent une même instance
|
|
// d'app entre les cas (construire une app coûte ~14 ms, multipliées par des
|
|
// milliers d'exécutions en tests de mutation). Sans ça, l'état du limiteur
|
|
// s'accumulerait d'un test à l'autre et ferait échouer le 11e. Les valeurs
|
|
// par défaut restent celles de production, et sont testées explicitement
|
|
// dans apps/api/test/rateLimit.test.js.
|
|
globalRateLimitMax = 1000,
|
|
adminRateLimitMax = 10,
|
|
authRateLimitMax = 10,
|
|
} = opts;
|
|
|
|
const fastify = Fastify({
|
|
logger,
|
|
bodyLimit: 10485760, // 10 MB max pour éviter DoS mémoire
|
|
trustProxy: true // derrière Traefik (+ nginx pour /admin/*) : lit X-Forwarded-For
|
|
});
|
|
|
|
// Headers de sécurité
|
|
await fastify.register(helmet, {
|
|
contentSecurityPolicy: false,
|
|
crossOriginEmbedderPolicy: false
|
|
});
|
|
|
|
await fastify.register(cookie);
|
|
|
|
// CORS middleware
|
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
const origin = request.headers.origin;
|
|
const allowedOrigins = corsOrigins;
|
|
|
|
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') {
|
|
// `return reply` est obligatoire : sans ça Fastify poursuit le cycle de vie
|
|
// et route quand même la requête OPTIONS vers un handler (ou un 404).
|
|
return reply.status(204).send();
|
|
}
|
|
});
|
|
|
|
// Rate limiting global
|
|
await fastify.register(rateLimit, {
|
|
max: globalRateLimitMax,
|
|
timeWindow: '1 minute',
|
|
cache: 10000
|
|
});
|
|
|
|
// Gestion d'erreurs globale
|
|
fastify.setErrorHandler((error, request, reply) => {
|
|
// Les erreurs 4xx (validation de schéma, rate-limit, payload trop gros…) ont
|
|
// un statusCode utile : l'écraser en 500 masquait la cause côté client et
|
|
// faisait passer des erreurs d'entrée pour des pannes serveur.
|
|
const status = Number(error.statusCode);
|
|
if (Number.isInteger(status) && status >= 400 && status < 500) {
|
|
return reply.status(status).send({ ok: false, error: error.message });
|
|
}
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({
|
|
ok: false,
|
|
error: 'Une erreur est survenue'
|
|
});
|
|
});
|
|
|
|
if (pool && seedAdmin) {
|
|
seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed"));
|
|
}
|
|
|
|
function requirePool() {
|
|
if (!pool) throw new Error("DATABASE_URL not set");
|
|
return pool;
|
|
}
|
|
|
|
function authBearer(req) {
|
|
const h = req.headers.authorization || "";
|
|
const m = h.match(/^Bearer\s+(.+)$/i);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
function requireAdmin(req, reply) {
|
|
const provided = authBearer(req);
|
|
|
|
if (!importToken || !provided) {
|
|
reply.code(401).send({ ok: false, error: "unauthorized" });
|
|
return false;
|
|
}
|
|
|
|
// Comparer les longueurs en OCTETS, pas en caractères : timingSafeEqual
|
|
// lève si les deux buffers diffèrent en taille, et un jeton multi-octets
|
|
// ("é" = 2 octets) passait le test de longueur de chaîne tout en produisant
|
|
// des buffers de tailles différentes → exception → 500 au lieu de 401.
|
|
const providedBuf = Buffer.from(provided, "utf8");
|
|
const expectedBuf = Buffer.from(importToken, "utf8");
|
|
if (providedBuf.length !== expectedBuf.length) {
|
|
reply.code(401).send({ ok: false, error: "unauthorized" });
|
|
return false;
|
|
}
|
|
|
|
const valid = timingSafeEqual(providedBuf, expectedBuf);
|
|
|
|
if (!valid) {
|
|
reply.code(401).send({ ok: false, error: "unauthorized" });
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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,
|
|
} = /** @type {Record<string, string|undefined>} */ (req.query || {});
|
|
|
|
if (!bbox || !zoom) {
|
|
return reply.code(400).send({ ok: false, error: "bbox and zoom are required" });
|
|
}
|
|
|
|
const bboxParts = String(bbox).split(",").map(Number);
|
|
if (bboxParts.length !== 4) {
|
|
return reply.code(400).send({ ok: false, error: "bbox must have 4 values" });
|
|
}
|
|
|
|
const [minLon, minLat, maxLon, maxLat] = bboxParts;
|
|
const zoomLevel = Number(zoom);
|
|
|
|
if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
|
|
return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" });
|
|
}
|
|
|
|
if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) {
|
|
return reply.code(400).send({ ok: false, error: "Coordinates out of range" });
|
|
}
|
|
|
|
if (minLon >= maxLon || minLat >= maxLat) {
|
|
return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" });
|
|
}
|
|
|
|
if (zoomLevel < 0 || zoomLevel > 22) {
|
|
return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" });
|
|
}
|
|
|
|
const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
|
|
|
|
// Source: band_locations (un point par localisation géocodée) JOIN bands.
|
|
const where = [
|
|
"bl.geom IS NOT NULL",
|
|
"bl.geocode_status IN ('done','country_only')",
|
|
];
|
|
const vals = [];
|
|
let i = 1;
|
|
|
|
where.push(`bl.geom && ST_MakeEnvelope($${i}, $${i+1}, $${i+2}, $${i+3}, 4326)`);
|
|
vals.push(minLon, minLat, maxLon, maxLat);
|
|
i += 4;
|
|
|
|
if (countries) {
|
|
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
|
if (cs.length > 100) {
|
|
return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
|
|
}
|
|
if (cs.length) {
|
|
where.push(`b.country = ANY($${i}::text[])`);
|
|
vals.push(cs);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
if (status) {
|
|
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
|
|
if (st.length > 50) {
|
|
return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
|
|
}
|
|
if (st.length) {
|
|
where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`);
|
|
vals.push(st);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
if (genre) {
|
|
try {
|
|
const genreQuery = sanitizeSearchString(genre, 'Genre');
|
|
where.push(`b.genre ILIKE $${i}`);
|
|
vals.push(`%${genreQuery}%`);
|
|
i++;
|
|
} catch (err) {
|
|
return reply.code(400).send({ ok: false, error: err.message });
|
|
}
|
|
}
|
|
|
|
if (year_min) {
|
|
const yearMin = Number(year_min);
|
|
if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) {
|
|
return reply.code(400).send({ ok: false, error: 'year_min invalide' });
|
|
}
|
|
where.push(`b.formed_year >= $${i}`);
|
|
vals.push(yearMin);
|
|
i++;
|
|
}
|
|
|
|
if (year_max) {
|
|
const yearMax = Number(year_max);
|
|
if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) {
|
|
return reply.code(400).send({ ok: false, error: 'year_max invalide' });
|
|
}
|
|
where.push(`b.formed_year <= $${i}`);
|
|
vals.push(yearMax);
|
|
i++;
|
|
}
|
|
|
|
const whereSql = where.join(" AND ");
|
|
|
|
if (zoomLevel >= 12) {
|
|
const sql = `
|
|
SELECT
|
|
b.ma_id,
|
|
b.name,
|
|
b.country,
|
|
COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
|
|
b.genre,
|
|
b.location_text,
|
|
b.formed_year,
|
|
bl.lat,
|
|
bl.lon,
|
|
bl.location_raw,
|
|
bl.step_order,
|
|
bl.step_label,
|
|
bl.is_country_only
|
|
FROM band_locations bl
|
|
JOIN bands b ON b.ma_id = bl.ma_id
|
|
WHERE ${whereSql}
|
|
ORDER BY bl.ma_id ASC, bl.step_order ASC
|
|
LIMIT 2000;
|
|
`;
|
|
const r = await p.query(sql, vals);
|
|
return {
|
|
ok: true,
|
|
type: "bands",
|
|
items: r.rows,
|
|
count: r.rows.length
|
|
};
|
|
}
|
|
|
|
const sql = `
|
|
WITH grid_cells AS (
|
|
SELECT
|
|
floor(ST_X(bl.geom) / $${i})::int as cell_x,
|
|
floor(ST_Y(bl.geom) / $${i})::int as cell_y,
|
|
b.ma_id,
|
|
b.name,
|
|
b.country,
|
|
COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
|
|
b.genre,
|
|
b.location_text,
|
|
b.formed_year,
|
|
bl.lat,
|
|
bl.lon,
|
|
bl.location_raw,
|
|
bl.step_label
|
|
FROM band_locations bl
|
|
JOIN bands b ON b.ma_id = bl.ma_id
|
|
WHERE ${whereSql}
|
|
),
|
|
clusters AS (
|
|
SELECT
|
|
cell_x,
|
|
cell_y,
|
|
count(*)::int as band_count,
|
|
avg(lat)::float8 as center_lat,
|
|
avg(lon)::float8 as center_lon,
|
|
array_agg(json_build_object(
|
|
'ma_id', ma_id,
|
|
'name', name,
|
|
'country', country,
|
|
'status', status,
|
|
'genre', genre,
|
|
'location_text', location_text,
|
|
'formed_year', formed_year,
|
|
'lat', lat,
|
|
'lon', lon,
|
|
'location_raw', location_raw,
|
|
'step_label', step_label
|
|
) ORDER BY name) as bands
|
|
FROM grid_cells
|
|
GROUP BY cell_x, cell_y
|
|
)
|
|
SELECT
|
|
center_lat as lat,
|
|
center_lon as lon,
|
|
band_count as count,
|
|
CASE
|
|
WHEN band_count <= 5 THEN bands
|
|
ELSE bands[1:5]
|
|
END as sample_bands
|
|
FROM clusters
|
|
ORDER BY band_count DESC
|
|
LIMIT 1000;
|
|
`;
|
|
vals.push(cellSize);
|
|
|
|
const r = await p.query(sql, vals);
|
|
return {
|
|
ok: true,
|
|
type: "clusters",
|
|
items: r.rows,
|
|
total_clusters: r.rows.length,
|
|
cell_size: cellSize,
|
|
zoom: zoomLevel
|
|
};
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des clusters' });
|
|
}
|
|
});
|
|
|
|
fastify.get("/api/bands", async (req, reply) => {
|
|
try {
|
|
const p = requirePool();
|
|
const {
|
|
countries,
|
|
geocoded,
|
|
q,
|
|
status,
|
|
only_black,
|
|
limit,
|
|
offset,
|
|
} = /** @type {Record<string, string|undefined>} */ (req.query || {});
|
|
|
|
let lim, off;
|
|
try {
|
|
({ limit: lim, offset: off } = parseLimitOffset(limit, offset, {
|
|
defaultLimit: 1000,
|
|
maxLimit: 150000,
|
|
}));
|
|
} catch (err) {
|
|
if (err instanceof ValidationError) {
|
|
return reply.code(400).send({ ok: false, error: err.message });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
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 { ma_id } = /** @type {{ ma_id: string }} */ (req.params);
|
|
const id = Number(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: adminRateLimitMax,
|
|
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 = /** @type {{ bands?: unknown[] }} */ (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 raw of bands) {
|
|
if (!raw || typeof raw !== "object") continue;
|
|
const b = /** @type {Record<string, any>} */ (raw);
|
|
|
|
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 query = /** @type {Record<string, string|undefined>} */ (req.query || {});
|
|
const limitRaw = Number(query.limit ?? 50);
|
|
const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500));
|
|
const country = (query.country ?? "").toString().trim().toUpperCase();
|
|
|
|
const params = [];
|
|
let where = "WHERE (data->'band_page') IS NULL";
|
|
|
|
if (country) {
|
|
params.push(country);
|
|
where += ` AND country = $${params.length}`;
|
|
}
|
|
|
|
params.push(limit);
|
|
|
|
const sql = `
|
|
SELECT
|
|
ma_id,
|
|
(data->>'url') AS url
|
|
FROM bands
|
|
${where}
|
|
ORDER BY ma_id ASC
|
|
LIMIT $${params.length}
|
|
`;
|
|
|
|
const r = await p.query(sql, params);
|
|
const items = (r.rows || []).filter((x) => x.url);
|
|
|
|
return { ok: true, count: items.length, items };
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des groupes à enrichir' });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// Auth dashboard admin (session cookie, distincte du token importToken)
|
|
// ------------------------------------------------------------------
|
|
fastify.register(async function (authRoutes) {
|
|
await authRoutes.register(rateLimit, {
|
|
max: authRateLimitMax,
|
|
timeWindow: "1 minute",
|
|
keyGenerator: (req) => req.ip,
|
|
});
|
|
|
|
authRoutes.post("/admin/auth/login", async (req, reply) => {
|
|
try {
|
|
const p = requirePool();
|
|
const { username, password } = /** @type {{ username?: unknown, password?: unknown }} */ (req.body || {});
|
|
if (typeof username !== "string" || typeof password !== "string" || !username || !password) {
|
|
return reply.code(400).send({ ok: false, error: "username et password requis" });
|
|
}
|
|
const uname = username.trim().slice(0, 100);
|
|
const ip = req.ip;
|
|
|
|
if (await isLockedOut(p, uname, ip)) {
|
|
return reply.code(429).send({ ok: false, error: "Trop de tentatives, réessayez dans quelques minutes" });
|
|
}
|
|
|
|
const valid = await verifyPassword(p, uname, password);
|
|
await recordLoginAttempt(p, uname, ip, valid);
|
|
|
|
if (!valid) {
|
|
return reply.code(401).send({ ok: false, error: "Identifiants invalides" });
|
|
}
|
|
|
|
await markLoginSuccess(p, uname);
|
|
const token = signAdminSession(uname);
|
|
reply.setCookie(ADMIN_COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "strict",
|
|
path: "/",
|
|
maxAge: 12 * 3600,
|
|
});
|
|
return { ok: true, username: uname };
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
return reply.code(500).send({ ok: false, error: "Erreur de connexion" });
|
|
}
|
|
});
|
|
|
|
authRoutes.post("/admin/auth/logout", async (req, reply) => {
|
|
reply.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
|
|
return { ok: true };
|
|
});
|
|
|
|
authRoutes.get("/admin/auth/me", async (req, reply) => {
|
|
const username = requireAdminSession(req, reply);
|
|
if (!username) return;
|
|
return { ok: true, username };
|
|
});
|
|
});
|
|
|
|
await fastify.register(adminApiRoutes, { pool });
|
|
|
|
await fastify.ready();
|
|
return fastify;
|
|
}
|