chore: initial commit — snapshot VPS 2026-06-27
- apps/api : API REST Node.js/Fastify + PostgreSQL/PostGIS - apps/geocoder : Worker Python géocodage Nominatim - apps/worker : Scraper Python Metal-Archives - apps/web : Frontend statique Leaflet/clustering/heatmap - infra/ : docker-compose + init.sql Déployé via Coolify + Traefik sur VPS OVH.
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
infra/.venv/
|
||||||
|
infra/temp/
|
||||||
|
infra/ma_state.json
|
||||||
|
infra/geocode-enqueue.log
|
||||||
|
infra/ma_debug.*
|
||||||
|
apps/api/node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
9
apps/api/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
16
apps/api/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "bm-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node src/server.js",
|
||||||
|
"start": "node src/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"@fastify/rate-limit": "^9.0.0",
|
||||||
|
"@fastify/helmet": "^11.1.1",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/api/src/index.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import pg from "pg";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// IMPORTANT: permettre le raw body (pour gzip)
|
||||||
|
fastify.addContentTypeParser("*", { parseAs: "buffer" }, (req, body, done) => done(null, body));
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
const IMPORT_TOKEN = process.env.BM_IMPORT_TOKEN || "";
|
||||||
|
|
||||||
|
let pool = null;
|
||||||
|
if (DATABASE_URL) pool = new Pool({ connectionString: DATABASE_URL });
|
||||||
|
|
||||||
|
fastify.get("/", async () => ({ ok: true, service: "bm-api" }));
|
||||||
|
fastify.get("/health", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
fastify.get("/db", async () => {
|
||||||
|
if (!pool) return { ok: false, error: "DATABASE_URL not set" };
|
||||||
|
const r = await pool.query("select now() as now, current_database() as db");
|
||||||
|
return { ok: true, ...r.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/stats", async () => {
|
||||||
|
if (!pool) return { ok: false, error: "DATABASE_URL not set" };
|
||||||
|
const r = await pool.query(`select (select count(*)::int from bands) as bands`);
|
||||||
|
return { ok: true, ...r.rows[0], updated_at: new Date().toISOString() };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/admin/import", async (req, reply) => {
|
||||||
|
const auth = String(req.headers.authorization || "");
|
||||||
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
|
|
||||||
|
if (!IMPORT_TOKEN || token !== IMPORT_TOKEN) {
|
||||||
|
return reply.code(401).send({ ok: false, error: "unauthorized" });
|
||||||
|
}
|
||||||
|
if (!pool) return reply.code(500).send({ ok: false, error: "DATABASE_URL not set" });
|
||||||
|
|
||||||
|
const ct = String(req.headers["content-type"] || "");
|
||||||
|
let payload;
|
||||||
|
|
||||||
|
// req.body est Buffer (grâce au parser "*")
|
||||||
|
const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
payload = JSON.parse(buf.toString("utf-8"));
|
||||||
|
} else if (ct.includes("application/gzip") || ct.includes("application/x-gzip")) {
|
||||||
|
payload = JSON.parse(zlib.gunzipSync(buf).toString("utf-8"));
|
||||||
|
} else {
|
||||||
|
// fallback: tenter JSON direct
|
||||||
|
payload = JSON.parse(buf.toString("utf-8"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
req.log.error(e);
|
||||||
|
return reply.code(400).send({ ok: false, error: "invalid body/json" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || !Array.isArray(payload.bands)) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "expected { bands: [...] }" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("begin");
|
||||||
|
let upserted = 0;
|
||||||
|
|
||||||
|
for (const b of payload.bands) {
|
||||||
|
if (!b?.ma_id || !b?.name) continue;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
insert into bands (ma_id, name, country, location_text, status, genre, data)
|
||||||
|
values ($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||||
|
on conflict (ma_id) do update set
|
||||||
|
name=excluded.name,
|
||||||
|
country=excluded.country,
|
||||||
|
location_text=excluded.location_text,
|
||||||
|
status=excluded.status,
|
||||||
|
genre=excluded.genre,
|
||||||
|
data=excluded.data
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
b.ma_id,
|
||||||
|
b.name,
|
||||||
|
b.country || null,
|
||||||
|
b.location_text || null,
|
||||||
|
b.status || null,
|
||||||
|
b.genre || null,
|
||||||
|
JSON.stringify(b.data || {}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("commit");
|
||||||
|
return { ok: true, upserted };
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("rollback");
|
||||||
|
req.log.error(e);
|
||||||
|
return reply.code(500).send({ ok: false, error: "import failed" });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
497
apps/api/src/server.js
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import pg from "pg";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normStatus(s) {
|
||||||
|
const v = String(s || "").replace(/\s+/g, " ").trim();
|
||||||
|
return v || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
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/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
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - bbox=minLon,minLat,maxLon,maxLat (required) - viewport bounds
|
||||||
|
* - zoom=N (required) - current zoom level
|
||||||
|
* - countries=FR,DE,IT (optional)
|
||||||
|
* - status=Active,Split-up (optional)
|
||||||
|
* - genre=black (optional, ILIKE)
|
||||||
|
* - year_min, year_max (optional)
|
||||||
|
*
|
||||||
|
* Returns clusters with count and representative bands
|
||||||
|
*/
|
||||||
|
fastify.get("/api/clusters", async (req) => {
|
||||||
|
const p = requirePool();
|
||||||
|
const {
|
||||||
|
bbox,
|
||||||
|
zoom,
|
||||||
|
countries,
|
||||||
|
status,
|
||||||
|
genre,
|
||||||
|
year_min,
|
||||||
|
year_max,
|
||||||
|
} = req.query || {};
|
||||||
|
|
||||||
|
if (!bbox || !zoom) {
|
||||||
|
return { ok: false, error: "bbox and zoom are required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number);
|
||||||
|
const zoomLevel = Number(zoom);
|
||||||
|
|
||||||
|
if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
|
||||||
|
return { ok: false, error: "Invalid bbox or zoom values" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate grid cell size based on zoom level
|
||||||
|
// Higher zoom = smaller cells = more detail
|
||||||
|
// At zoom 4 (continental) ~2 degrees, at zoom 10 (city) ~0.05 degrees
|
||||||
|
const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
|
||||||
|
|
||||||
|
const where = ["geom IS NOT NULL"];
|
||||||
|
const vals = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
// Bounding box filter using PostGIS
|
||||||
|
where.push(`ST_X(geom) >= $${i} AND ST_X(geom) <= $${i+1} AND ST_Y(geom) >= $${i+2} AND ST_Y(geom) <= $${i+3}`);
|
||||||
|
vals.push(minLon, maxLon, minLat, maxLat);
|
||||||
|
i += 4;
|
||||||
|
|
||||||
|
if (countries) {
|
||||||
|
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
||||||
|
if (cs.length) {
|
||||||
|
where.push(`country = ANY($${i}::text[])`);
|
||||||
|
vals.push(cs);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
if (st.length) {
|
||||||
|
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
||||||
|
vals.push(st);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
where.push(`genre ILIKE $${i}`);
|
||||||
|
vals.push(`%${String(genre).trim()}%`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year_min) {
|
||||||
|
where.push(`formed_year >= $${i}`);
|
||||||
|
vals.push(Number(year_min));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year_max) {
|
||||||
|
where.push(`formed_year <= $${i}`);
|
||||||
|
vals.push(Number(year_max));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.join(" AND ");
|
||||||
|
|
||||||
|
// For high zoom levels (>= 12), return individual bands
|
||||||
|
if (zoomLevel >= 12) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ma_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
country,
|
||||||
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
|
genre,
|
||||||
|
location_text,
|
||||||
|
formed_year,
|
||||||
|
ST_Y(geom)::float8 as lat,
|
||||||
|
ST_X(geom)::float8 as lon
|
||||||
|
FROM bands
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY ma_id ASC
|
||||||
|
LIMIT 2000;
|
||||||
|
`;
|
||||||
|
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) / $${i})::int as cell_x,
|
||||||
|
floor(ST_Y(geom) / $${i})::int as cell_y,
|
||||||
|
ma_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
country,
|
||||||
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
|
genre,
|
||||||
|
location_text,
|
||||||
|
formed_year,
|
||||||
|
ST_Y(geom)::float8 as lat,
|
||||||
|
ST_X(geom)::float8 as lon
|
||||||
|
FROM bands
|
||||||
|
WHERE ${whereSql}
|
||||||
|
),
|
||||||
|
clusters AS (
|
||||||
|
SELECT
|
||||||
|
cell_x,
|
||||||
|
cell_y,
|
||||||
|
count(*)::int as band_count,
|
||||||
|
avg(lat)::float8 as center_lat,
|
||||||
|
avg(lon)::float8 as center_lon,
|
||||||
|
array_agg(json_build_object(
|
||||||
|
'ma_id', ma_id,
|
||||||
|
'name', name,
|
||||||
|
'url', url,
|
||||||
|
'country', country,
|
||||||
|
'status', status,
|
||||||
|
'genre', genre,
|
||||||
|
'location_text', location_text,
|
||||||
|
'formed_year', formed_year,
|
||||||
|
'lat', lat,
|
||||||
|
'lon', lon
|
||||||
|
) ORDER BY name) as bands
|
||||||
|
FROM grid_cells
|
||||||
|
GROUP BY cell_x, cell_y
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
center_lat as lat,
|
||||||
|
center_lon as lon,
|
||||||
|
band_count as count,
|
||||||
|
CASE
|
||||||
|
WHEN band_count <= 5 THEN bands
|
||||||
|
ELSE bands[1:5]
|
||||||
|
END as sample_bands
|
||||||
|
FROM clusters
|
||||||
|
ORDER BY band_count DESC
|
||||||
|
LIMIT 1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
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, kept for compatibility
|
||||||
|
* For better performance, use /api/clusters with viewport
|
||||||
|
*/
|
||||||
|
fastify.get("/api/bands", async (req) => {
|
||||||
|
const p = requirePool();
|
||||||
|
const {
|
||||||
|
countries,
|
||||||
|
geocoded,
|
||||||
|
q,
|
||||||
|
status,
|
||||||
|
only_black,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = req.query || {};
|
||||||
|
|
||||||
|
const lim = Math.min(Number(limit || 20000), 50000);
|
||||||
|
const off = Math.max(Number(offset || 0), 0);
|
||||||
|
|
||||||
|
const where = [];
|
||||||
|
const vals = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (countries) {
|
||||||
|
const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
||||||
|
if (cs.length) {
|
||||||
|
where.push(`country = ANY($${i}::text[])`);
|
||||||
|
vals.push(cs);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geocoded === "1") where.push(`geom IS NOT NULL`);
|
||||||
|
if (geocoded === "0") where.push(`geom IS NULL`);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
if (st.length) {
|
||||||
|
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
|
||||||
|
vals.push(st);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (only_black === "1") {
|
||||||
|
where.push(`genre ILIKE '%black%'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const qq = `%${String(q).trim()}%`;
|
||||||
|
where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`);
|
||||||
|
vals.push(qq);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ma_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
country,
|
||||||
|
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
|
||||||
|
genre,
|
||||||
|
location_text,
|
||||||
|
enriched,
|
||||||
|
formed_year,
|
||||||
|
themes,
|
||||||
|
CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat,
|
||||||
|
CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon
|
||||||
|
FROM bands
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ma_id ASC
|
||||||
|
LIMIT ${lim} OFFSET ${off};
|
||||||
|
`;
|
||||||
|
const r = await p.query(sql, vals);
|
||||||
|
return { ok: true, items: r.rows, limit: lim, offset: off };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minimal data for initial load (facets only, no band details)
|
||||||
|
* Used to populate filters without loading all bands
|
||||||
|
*/
|
||||||
|
fastify.get("/api/facets", async () => {
|
||||||
|
const p = requirePool();
|
||||||
|
|
||||||
|
// Get all facet data in parallel
|
||||||
|
const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
|
||||||
|
p.query(`
|
||||||
|
SELECT COALESCE(NULLIF(trim(status), ''), 'Unknown') as value, count(*)::int as count
|
||||||
|
FROM bands WHERE geom IS NOT NULL
|
||||||
|
GROUP BY 1 ORDER BY count DESC
|
||||||
|
`),
|
||||||
|
p.query(`
|
||||||
|
SELECT COALESCE(country, '??') as value, count(*)::int as count
|
||||||
|
FROM bands WHERE geom IS NOT NULL
|
||||||
|
GROUP BY 1 ORDER BY count DESC
|
||||||
|
`),
|
||||||
|
p.query(`
|
||||||
|
SELECT genre as value, count(*)::int as count
|
||||||
|
FROM bands WHERE geom IS NOT NULL AND genre IS NOT NULL AND genre != ''
|
||||||
|
GROUP BY 1 ORDER BY count DESC
|
||||||
|
LIMIT 500
|
||||||
|
`),
|
||||||
|
p.query(`
|
||||||
|
SELECT MIN(formed_year)::int as min_year, MAX(formed_year)::int as max_year
|
||||||
|
FROM bands WHERE geom IS NOT NULL AND formed_year >= 1900 AND formed_year <= extract(year from now())
|
||||||
|
`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
statuses: statusRes.rows,
|
||||||
|
countries: countryRes.rows,
|
||||||
|
genres: genreRes.rows,
|
||||||
|
year_range: yearRes.rows[0] || { min_year: null, max_year: null }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détail d'un groupe (pour modal "fiche")
|
||||||
|
*/
|
||||||
|
fastify.get("/api/band/:ma_id", async (req, reply) => {
|
||||||
|
const p = requirePool();
|
||||||
|
const id = Number(req.params.ma_id);
|
||||||
|
if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad ma_id" });
|
||||||
|
|
||||||
|
const r = await p.query(
|
||||||
|
`SELECT ma_id, name, url, country, status, genre, location_text, enriched, data,
|
||||||
|
formed_year, themes,
|
||||||
|
CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat,
|
||||||
|
CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon,
|
||||||
|
geocoded_at
|
||||||
|
FROM bands
|
||||||
|
WHERE ma_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
|
||||||
|
return { ok: true, item: r.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN import
|
||||||
|
*/
|
||||||
|
fastify.post("/admin/import", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
if (!body || typeof body !== "object") return reply.code(400).send({ ok: false, error: "invalid body/json" });
|
||||||
|
const bands = Array.isArray(body.bands) ? body.bands : null;
|
||||||
|
if (!bands) return reply.code(400).send({ ok: false, error: "missing bands[]" });
|
||||||
|
|
||||||
|
const p = requirePool();
|
||||||
|
|
||||||
|
let upserted = 0;
|
||||||
|
for (const b of bands) {
|
||||||
|
if (!b || typeof b !== "object") continue;
|
||||||
|
const ma_id = Number(b.ma_id);
|
||||||
|
if (!Number.isFinite(ma_id)) continue;
|
||||||
|
|
||||||
|
const name = b.name ?? null;
|
||||||
|
const url = b.url ?? (b.data?.url ?? null);
|
||||||
|
const country = b.country ?? null;
|
||||||
|
const status = b.status ?? null;
|
||||||
|
const genre = b.genre ?? null;
|
||||||
|
const location_text = b.location_text ?? b.location ?? null;
|
||||||
|
|
||||||
|
const hasData = b.data && typeof b.data === "object";
|
||||||
|
const data = hasData ? b.data : null;
|
||||||
|
const enriched = hasData ? true : (b.enriched ?? null);
|
||||||
|
|
||||||
|
await p.query(
|
||||||
|
`
|
||||||
|
INSERT INTO bands (ma_id, name, url, country, status, genre, location_text, data, enriched)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9,false))
|
||||||
|
ON CONFLICT (ma_id) DO UPDATE SET
|
||||||
|
name = COALESCE(EXCLUDED.name, bands.name),
|
||||||
|
url = COALESCE(EXCLUDED.url, bands.url),
|
||||||
|
country = COALESCE(EXCLUDED.country, bands.country),
|
||||||
|
status = COALESCE(EXCLUDED.status, bands.status),
|
||||||
|
genre = COALESCE(EXCLUDED.genre, bands.genre),
|
||||||
|
location_text = COALESCE(EXCLUDED.location_text, bands.location_text),
|
||||||
|
data = COALESCE(EXCLUDED.data, bands.data),
|
||||||
|
enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
|
||||||
|
`,
|
||||||
|
[ma_id, name, url, country, status, genre, location_text, data, enriched]
|
||||||
|
);
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ ok: true, upserted });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
12
apps/geocoder/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier et installer les dépendances en premier (meilleur cache Docker)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copier le code de l'application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Le command sera spécifié dans docker-compose.yml
|
||||||
2
apps/geocoder/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
requests==2.32.3
|
||||||
109
apps/geocoder/src/enqueue.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
COUNTRY_FALLBACK = {
|
||||||
|
"FR": "France",
|
||||||
|
"DE": "Germany",
|
||||||
|
"IT": "Italy",
|
||||||
|
"GB": "United Kingdom",
|
||||||
|
"ES": "Spain",
|
||||||
|
"SE": "Sweden",
|
||||||
|
"NO": "Norway",
|
||||||
|
"FI": "Finland",
|
||||||
|
"PL": "Poland",
|
||||||
|
"NL": "Netherlands",
|
||||||
|
"BE": "Belgium",
|
||||||
|
"CH": "Switzerland",
|
||||||
|
"AT": "Austria",
|
||||||
|
"PT": "Portugal",
|
||||||
|
"GR": "Greece",
|
||||||
|
"CZ": "Czech Republic",
|
||||||
|
"SK": "Slovakia",
|
||||||
|
"SI": "Slovenia",
|
||||||
|
"HU": "Hungary",
|
||||||
|
"UA": "Ukraine",
|
||||||
|
"RU": "Russia",
|
||||||
|
"DK": "Denmark",
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_query(location_text: str, country_code: str) -> str:
|
||||||
|
loc = (location_text or "").strip()
|
||||||
|
cc = (country_code or "").strip().upper()
|
||||||
|
ctry = COUNTRY_FALLBACK.get(cc, cc)
|
||||||
|
return f"{loc}, {ctry}" if loc else ctry
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dsn = os.environ["DATABASE_URL"]
|
||||||
|
batch = int(os.environ.get("GEOCODE_ENQUEUE_BATCH", "5000"))
|
||||||
|
|
||||||
|
# Retry policy
|
||||||
|
retry_after_hours = int(os.environ.get("GEOCODE_RETRY_AFTER_HOURS", "72")) # 72h = 3 jours
|
||||||
|
force_retry = os.environ.get("GEOCODE_FORCE_RETRY", "0").strip() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
conn.autocommit = True
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# On sélectionne :
|
||||||
|
# - bands non géocodés avec location_text
|
||||||
|
# - soit pas encore en queue, soit queue en error/queued et next_run_at <= now()
|
||||||
|
# - et si erreur récente: on respecte retry_after_hours sauf si force_retry
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
b.ma_id, b.country, b.location_text
|
||||||
|
FROM bands b
|
||||||
|
LEFT JOIN geocode_queue q ON q.ma_id = b.ma_id
|
||||||
|
WHERE b.geom IS NULL
|
||||||
|
AND b.location_text IS NOT NULL AND b.location_text <> ''
|
||||||
|
AND (
|
||||||
|
q.ma_id IS NULL
|
||||||
|
OR (q.status IN ('queued','error') AND q.next_run_at <= now())
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
%s
|
||||||
|
OR b.geocode_error IS NULL
|
||||||
|
OR b.geocode_error_at IS NULL
|
||||||
|
OR b.geocode_error_at < now() - (%s || ' hours')::interval
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(q.next_run_at, now()) ASC,
|
||||||
|
b.ma_id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(force_retry, retry_after_hours, batch),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
enq = 0
|
||||||
|
|
||||||
|
for ma_id, country, location_text in rows:
|
||||||
|
qtxt = make_query(location_text, country)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO geocode_queue (ma_id, query, country, status, next_run_at)
|
||||||
|
VALUES (%s, %s, %s, 'queued', now())
|
||||||
|
ON CONFLICT (ma_id) DO UPDATE
|
||||||
|
SET query = EXCLUDED.query,
|
||||||
|
country = EXCLUDED.country,
|
||||||
|
status = CASE
|
||||||
|
WHEN geocode_queue.status IN ('done','processing') THEN geocode_queue.status
|
||||||
|
ELSE 'queued'
|
||||||
|
END,
|
||||||
|
next_run_at = CASE
|
||||||
|
WHEN geocode_queue.status IN ('done','processing') THEN geocode_queue.next_run_at
|
||||||
|
ELSE now()
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(ma_id, qtxt, country),
|
||||||
|
)
|
||||||
|
enq += 1
|
||||||
|
|
||||||
|
print(f"[enqueue] queued_or_updated={enq} (selected={len(rows)}) force_retry={force_retry} retry_after_hours={retry_after_hours}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
200
apps/geocoder/src/worker.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
NOMINATIM_BASE = os.environ.get("NOMINATIM_BASE", "https://nominatim.openstreetmap.org").rstrip("/")
|
||||||
|
NOMINATIM_EMAIL = os.environ.get("NOMINATIM_EMAIL", "").strip()
|
||||||
|
USER_AGENT = os.environ.get("NOMINATIM_USER_AGENT", "bm-geocoder/0.1 (contact: you@example.com)").strip()
|
||||||
|
|
||||||
|
MIN_DELAY = float(os.environ.get("NOMINATIM_MIN_DELAY", "1.05")) # >= 1s
|
||||||
|
JITTER = float(os.environ.get("NOMINATIM_JITTER", "0.35"))
|
||||||
|
MAX_PER_RUN = int(os.environ.get("GEOCODE_MAX_PER_RUN", "100000"))
|
||||||
|
|
||||||
|
_last = 0.0
|
||||||
|
|
||||||
|
def polite_sleep():
|
||||||
|
global _last
|
||||||
|
elapsed = time.monotonic() - _last
|
||||||
|
wait = max(0.0, MIN_DELAY - elapsed) + random.uniform(0.0, JITTER)
|
||||||
|
time.sleep(wait)
|
||||||
|
_last = time.monotonic()
|
||||||
|
|
||||||
|
def nominatim_search(query: str) -> dict | None:
|
||||||
|
# doc: /search + email conseillé pour gros volume :contentReference[oaicite:1]{index=1}
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"format": "jsonv2",
|
||||||
|
"limit": 1,
|
||||||
|
"addressdetails": 1,
|
||||||
|
}
|
||||||
|
if NOMINATIM_EMAIL:
|
||||||
|
params["email"] = NOMINATIM_EMAIL
|
||||||
|
|
||||||
|
headers = {"User-Agent": USER_AGENT} # requis par policy :contentReference[oaicite:2]{index=2}
|
||||||
|
|
||||||
|
polite_sleep()
|
||||||
|
r = requests.get(f"{NOMINATIM_BASE}/search", params=params, headers=headers, timeout=30)
|
||||||
|
if r.status_code in (403, 429, 503):
|
||||||
|
raise RuntimeError(f"nominatim_throttle status={r.status_code} body={r.text[:200]}")
|
||||||
|
r.raise_for_status()
|
||||||
|
arr = r.json()
|
||||||
|
if not arr:
|
||||||
|
return None
|
||||||
|
return arr[0]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dsn = os.environ["DATABASE_URL"]
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
conn.autocommit = True
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
while processed < MAX_PER_RUN:
|
||||||
|
# prend 1 job à faire
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT ma_id, query, country, tries
|
||||||
|
FROM geocode_queue
|
||||||
|
WHERE status IN ('queued','error')
|
||||||
|
AND next_run_at <= now()
|
||||||
|
ORDER BY next_run_at ASC, ma_id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
print("[worker] nothing to do. sleeping 60s")
|
||||||
|
time.sleep(60)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ma_id, query, country, tries = row
|
||||||
|
|
||||||
|
# marque processing
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE geocode_queue
|
||||||
|
SET status='processing', updated_at=now()
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
(ma_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1) cache ?
|
||||||
|
cur.execute("SELECT lat, lon, raw FROM geocode_cache WHERE query=%s", (query,))
|
||||||
|
cached = cur.fetchone()
|
||||||
|
if cached and cached[0] is not None and cached[1] is not None:
|
||||||
|
lat, lon, raw = cached
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bands
|
||||||
|
SET lat=%s, lon=%s,
|
||||||
|
geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
||||||
|
geocoded_at=now(),
|
||||||
|
geocode_provider='nominatim-cache',
|
||||||
|
geocode_query=%s,
|
||||||
|
geocode_raw=%s,
|
||||||
|
geocode_error=NULL,
|
||||||
|
geocode_error_at=NULL
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
(lat, lon, lon, lat, query, json.dumps(raw), ma_id),
|
||||||
|
)
|
||||||
|
cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,))
|
||||||
|
processed += 1
|
||||||
|
print(f"[worker] ma_id={ma_id} cache HIT -> done")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2) requête nominatim
|
||||||
|
try:
|
||||||
|
res = nominatim_search(query)
|
||||||
|
if not res:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bands
|
||||||
|
SET geocode_error=%s, geocode_error_at=now()
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
("no_result", ma_id),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE geocode_queue
|
||||||
|
SET status='error', tries=tries+1, last_error=%s,
|
||||||
|
next_run_at=now() + interval '7 days',
|
||||||
|
updated_at=now()
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
("no_result", ma_id),
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
print(f"[worker] ma_id={ma_id} no_result -> postpone")
|
||||||
|
continue
|
||||||
|
|
||||||
|
lat = float(res["lat"])
|
||||||
|
lon = float(res["lon"])
|
||||||
|
|
||||||
|
# écrit cache
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO geocode_cache(query, provider, lat, lon, geom, raw, updated_at)
|
||||||
|
VALUES (%s,'nominatim',%s,%s,ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,%s,now())
|
||||||
|
ON CONFLICT (query) DO UPDATE
|
||||||
|
SET lat=EXCLUDED.lat, lon=EXCLUDED.lon, geom=EXCLUDED.geom,
|
||||||
|
raw=EXCLUDED.raw, updated_at=now()
|
||||||
|
""",
|
||||||
|
(query, lat, lon, lon, lat, json.dumps(res)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# écrit bands
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bands
|
||||||
|
SET lat=%s, lon=%s,
|
||||||
|
geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
||||||
|
geocoded_at=now(),
|
||||||
|
geocode_provider='nominatim',
|
||||||
|
geocode_query=%s,
|
||||||
|
geocode_raw=%s,
|
||||||
|
geocode_error=NULL,
|
||||||
|
geocode_error_at=NULL
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
(lat, lon, lon, lat, query, json.dumps(res), ma_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,))
|
||||||
|
processed += 1
|
||||||
|
print(f"[worker] ma_id={ma_id} OK lat={lat} lon={lon}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# backoff progressif
|
||||||
|
backoff_minutes = min(60, 5 * (tries + 1))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bands
|
||||||
|
SET geocode_error=%s, geocode_error_at=now()
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
(str(e)[:400], ma_id),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE geocode_queue
|
||||||
|
SET status='error', tries=tries+1, last_error=%s,
|
||||||
|
next_run_at=now() + (%s || ' minutes')::interval,
|
||||||
|
updated_at=now()
|
||||||
|
WHERE ma_id=%s
|
||||||
|
""",
|
||||||
|
(str(e)[:400], backoff_minutes, ma_id),
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
print(f"[worker] ma_id={ma_id} ERROR {e} -> retry in {backoff_minutes}m")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
51
apps/web/docker-compose.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
services:
|
||||||
|
bm-web:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: bm-web
|
||||||
|
networks:
|
||||||
|
- stack-vps_admin
|
||||||
|
volumes:
|
||||||
|
- ./site:/usr/share/nginx/html:ro
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
|
||||||
|
# ✅ règle de matching (OBLIGATOIRE)
|
||||||
|
- traefik.http.routers.bm-web.rule=Host(`metalfrom.eu`) || Host(`www.metalfrom.eu`)
|
||||||
|
|
||||||
|
- traefik.http.routers.bm-web.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-web.tls=true
|
||||||
|
- traefik.http.routers.bm-web.tls.certresolver=le
|
||||||
|
|
||||||
|
# ✅ force cert apex + www
|
||||||
|
- traefik.http.routers.bm-web.tls.domains[0].main=metalfrom.eu
|
||||||
|
- traefik.http.routers.bm-web.tls.domains[0].sans=www.metalfrom.eu
|
||||||
|
- traefik.http.routers.bm-web.service=bm-web
|
||||||
|
- traefik.http.services.bm-web.loadbalancer.server.port=80
|
||||||
|
quizz:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: quizz
|
||||||
|
networks:
|
||||||
|
- stack-vps_admin
|
||||||
|
volumes:
|
||||||
|
- ./quizz-site:/usr/share/nginx/html:ro
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
|
||||||
|
# ✅ règle de matching (OBLIGATOIRE)
|
||||||
|
- traefik.http.routers.quizz.rule=Host(`quizz.nicolasfryder.ovh`)
|
||||||
|
|
||||||
|
- traefik.http.routers.quizz.entrypoints=websecure
|
||||||
|
- traefik.http.routers.quizz.tls=true
|
||||||
|
- traefik.http.routers.quizz.tls.certresolver=le
|
||||||
|
|
||||||
|
# ✅ force cert apex + www
|
||||||
|
- traefik.http.routers.quizz.tls.domains[0].main=quizz.nicolasfryder.ovh
|
||||||
|
- traefik.http.routers.quizz.service=quizz
|
||||||
|
- traefik.http.services.quizz.loadbalancer.server.port=80
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
stack-vps_admin:
|
||||||
|
external: true
|
||||||
BIN
apps/web/quizz-site/axolotl.jpeg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
apps/web/quizz-site/echidne.jpeg
Normal file
|
After Width: | Height: | Size: 42 KiB |
696
apps/web/quizz-site/index.html
Normal file
|
|
@ -0,0 +1,696 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>✨ Le Quiz des Équipes</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,700;0,900;1,700&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e17;
|
||||||
|
--surface: #1a1929;
|
||||||
|
--border: rgba(255,255,255,0.08);
|
||||||
|
--text: #fffffe;
|
||||||
|
--muted: rgba(255,255,255,0.45);
|
||||||
|
--saiga: #f7b731;
|
||||||
|
--axolotl: #26d0ce;
|
||||||
|
--ratel: #ff6b6b;
|
||||||
|
--poule: #ffd166;
|
||||||
|
--echidne: #c77dff;
|
||||||
|
--accent: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 50% at 20% 10%, rgba(199,125,255,0.12) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 80% 90%, rgba(255,107,107,0.1) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
padding: 1.5rem 1.25rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SCREEN TRANSITIONS ─── */
|
||||||
|
.screen {
|
||||||
|
display: none;
|
||||||
|
animation: fadeUp .45s cubic-bezier(.22,1,.36,1) both;
|
||||||
|
}
|
||||||
|
.screen.active { display: block; }
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(28px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── HEADER ─── */
|
||||||
|
.quiz-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.quiz-header .eyebrow {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
.quiz-header h1 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: clamp(2rem, 8vw, 2.8rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.05;
|
||||||
|
background: linear-gradient(135deg, #fff 30%, var(--echidne));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.quiz-header p {
|
||||||
|
margin-top: .6rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── NAME SELECTION ─── */
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
.search-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: .85rem 1.1rem .85rem 2.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.search-wrap input:focus { border-color: rgba(199,125,255,.5); }
|
||||||
|
.search-wrap input::placeholder { color: var(--muted); font-weight: 400; }
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: .95rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.names-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: .55rem;
|
||||||
|
max-height: 58vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: .2rem;
|
||||||
|
}
|
||||||
|
.names-grid::-webkit-scrollbar { width: 4px; }
|
||||||
|
.names-grid::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.names-grid::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 4px; }
|
||||||
|
|
||||||
|
.name-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .7rem .9rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: .88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all .18s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.name-btn:hover {
|
||||||
|
border-color: rgba(199,125,255,.5);
|
||||||
|
background: rgba(199,125,255,.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.name-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 2rem 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── QUIZ ─── */
|
||||||
|
.progress-track {
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
border-radius: 99px;
|
||||||
|
height: 4px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--echidne), var(--ratel));
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: width .4s cubic-bezier(.22,1,.36,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-meta {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
.question-text {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: clamp(1.25rem, 5vw, 1.6rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-bottom: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .65rem;
|
||||||
|
}
|
||||||
|
.choice-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: .95rem 1.15rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .75rem;
|
||||||
|
transition: all .18s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.choice-btn .letter {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .2rem .45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: .05rem;
|
||||||
|
}
|
||||||
|
.choice-btn:hover {
|
||||||
|
border-color: rgba(199,125,255,.5);
|
||||||
|
background: rgba(199,125,255,.07);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
.choice-btn:active { transform: scale(.97); }
|
||||||
|
.choice-btn.selected {
|
||||||
|
border-color: var(--echidne);
|
||||||
|
background: rgba(199,125,255,.13);
|
||||||
|
}
|
||||||
|
.choice-btn.selected .letter {
|
||||||
|
background: var(--echidne);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
.btn-next {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .8rem 1.8rem;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .18s;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.btn-next.visible { opacity: 1; pointer-events: all; }
|
||||||
|
.btn-next:hover { transform: scale(1.04); }
|
||||||
|
.btn-next:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
/* ─── RESULT ─── */
|
||||||
|
.result-screen {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
||||||
|
.result-avatar-wrap {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 1.2rem;
|
||||||
|
padding: 3px;
|
||||||
|
animation: popIn .5s cubic-bezier(.34,1.56,.64,1) .1s both;
|
||||||
|
/* border color set inline via JS */
|
||||||
|
}
|
||||||
|
.result-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
@keyframes popIn {
|
||||||
|
from { opacity: 0; transform: scale(.4) rotate(-10deg); }
|
||||||
|
to { opacity: 1; transform: scale(1) rotate(0deg); }
|
||||||
|
}
|
||||||
|
.result-greeting {
|
||||||
|
font-size: .85rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
animation: fadeUp .4s .2s both;
|
||||||
|
}
|
||||||
|
.result-name {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: fadeUp .4s .25s both;
|
||||||
|
}
|
||||||
|
.result-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .5rem 1.4rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
animation: fadeUp .4s .3s both;
|
||||||
|
}
|
||||||
|
.result-desc {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: rgba(255,255,255,.8);
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto 1.6rem;
|
||||||
|
animation: fadeUp .4s .35s both;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restart {
|
||||||
|
background: transparent;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .75rem 1.6rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .18s;
|
||||||
|
animation: fadeUp .4s .5s both;
|
||||||
|
}
|
||||||
|
.btn-restart:hover { color: var(--text); border-color: rgba(255,255,255,.3); }
|
||||||
|
|
||||||
|
/* ─── CONFETTI ─── */
|
||||||
|
.confetti-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.confetto {
|
||||||
|
position: absolute;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
top: -20px;
|
||||||
|
animation: confettiFall linear forwards;
|
||||||
|
}
|
||||||
|
@keyframes confettiFall {
|
||||||
|
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { transform: translateY(105vh) rotate(720deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="confetti-container" id="confetti"></div>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN 1: NAME SELECTION ═══ -->
|
||||||
|
<div class="screen active" id="screen-name">
|
||||||
|
<div class="quiz-header">
|
||||||
|
<div class="eyebrow">🎉 Weekend Anniversaire</div>
|
||||||
|
<h1>Qui es-tu<br>ce weekend ?</h1>
|
||||||
|
<p>Sélectionne ton prénom pour découvrir<br>dans quelle équipe tu es.</p>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" id="name-search" placeholder="Recherche ton prénom…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="names-grid" id="names-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN 2: QUIZ ═══ -->
|
||||||
|
<div class="screen" id="screen-quiz">
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="question-meta" id="q-meta"></div>
|
||||||
|
<div class="question-text" id="q-text"></div>
|
||||||
|
<div class="choices" id="q-choices"></div>
|
||||||
|
<div class="quiz-nav">
|
||||||
|
<button class="btn-next" id="btn-next">Suivant →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN 3: RESULT ═══ -->
|
||||||
|
<div class="screen" id="screen-result">
|
||||||
|
<div class="result-screen">
|
||||||
|
<div class="result-avatar-wrap" id="r-avatar-wrap">
|
||||||
|
<img class="result-avatar" id="r-avatar" src="" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="result-greeting">Bienvenue dans l'équipe</div>
|
||||||
|
<div class="result-badge" id="r-badge"></div>
|
||||||
|
<div class="result-name" id="r-name"></div>
|
||||||
|
<div class="result-desc" id="r-desc"></div>
|
||||||
|
<button class="btn-restart" id="btn-restart">← Recommencer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// DATA
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
const TEAMS = {
|
||||||
|
SAÏGA: {
|
||||||
|
img: 'saiga.jpeg',
|
||||||
|
color: '#f7b731',
|
||||||
|
desc: "Cerveau en 4D. Trop d'analyse, pas assez de repos.",
|
||||||
|
members: ['Kawthar','Alexandra','Yoann','Hugo','Tung','Antoine GAB','Aldéric']
|
||||||
|
},
|
||||||
|
AXOLOTL: {
|
||||||
|
img: 'axolotl.jpeg',
|
||||||
|
color: '#26d0ce',
|
||||||
|
desc: 'Tu gères ton chaos avec une grâce étrange. Adapté·e, mais à quel prix.',
|
||||||
|
members: ['Charlène','Lina','Cécile','Philipp','Léon','Alex Clarisse']
|
||||||
|
},
|
||||||
|
RATEL: {
|
||||||
|
img: 'ratel.jpeg',
|
||||||
|
color: '#ff6b6b',
|
||||||
|
desc: 'Agent du chaos. Tu fonces, tu vois après. Et souvent… ça passe.',
|
||||||
|
members: ['Antoine LAMB','Baptiste JAOU','Baptiste JEAN','Astrid','Thimo','Tiphaine','Floriane','Clarisse BA','Marine','Léonie']
|
||||||
|
},
|
||||||
|
'POULE DE SOIE': {
|
||||||
|
img: 'poule-de-soie.jpeg',
|
||||||
|
color: '#ffd166',
|
||||||
|
desc: 'Doux·ce, perdu·e, essentiel·le. Tu tiens le groupe sans le savoir.',
|
||||||
|
members: ['Laurine','Alice','Indra','Zhong','Laurie','Hawa','Margaux','Alexandre P','Thijs']
|
||||||
|
},
|
||||||
|
ECHIDNÉ: {
|
||||||
|
img: 'echidne.jpeg',
|
||||||
|
color: '#c77dff',
|
||||||
|
desc: 'Tu veux juste survivre tranquillement. Soft extérieur, panique intérieure.',
|
||||||
|
members: ['Emma','Clarisse Authier','Christophe','Alexander','Mélanie','Elsa','Samuel','Tes']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build name → team map
|
||||||
|
const NAME_TO_TEAM = {};
|
||||||
|
Object.entries(TEAMS).forEach(([teamName, data]) => {
|
||||||
|
data.members.forEach(m => NAME_TO_TEAM[m] = teamName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALL_NAMES = Object.values(TEAMS).flatMap(t => t.members).sort((a,b) => a.localeCompare(b,'fr'));
|
||||||
|
|
||||||
|
const QUESTIONS = [
|
||||||
|
{
|
||||||
|
text: 'Ton pire cauchemar',
|
||||||
|
choices: [
|
||||||
|
'Être coincé·e dans un call Teams sans fin',
|
||||||
|
'Outlook qui plante juste avant un envoi crucial',
|
||||||
|
'Une notif Slack à 23h "petite question rapide"',
|
||||||
|
'Devoir "networker" avec des gens motivés',
|
||||||
|
"Que quelqu'un dise \"on fait un tour de table\""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Ta maison à Poudlard',
|
||||||
|
choices: [
|
||||||
|
'Serpentard (ambition + fatigue)',
|
||||||
|
'Poufsouffle (gentil·le mais épuisé·e)',
|
||||||
|
'Gryffondor (audace discutable)',
|
||||||
|
'Serdaigle (cerveau en surchauffe)',
|
||||||
|
'Refuse de choisir, trop de pression'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Ton plaisir coupable',
|
||||||
|
choices: [
|
||||||
|
'Annuler des plans',
|
||||||
|
'Regarder des apparts hors budget',
|
||||||
|
'Acheter du matos de sport inutile',
|
||||||
|
'Dire "je me couche tôt" puis scroller 2h',
|
||||||
|
'Regarder des gens mieux organisés que toi'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Ton état un dimanche soir',
|
||||||
|
choices: [
|
||||||
|
'Mélancolie élégante',
|
||||||
|
'Angoisse administrative',
|
||||||
|
'Déni total',
|
||||||
|
'Organisation excessive',
|
||||||
|
'Snack + série + oubli volontaire'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Quel fromage es-tu ?',
|
||||||
|
choices: [
|
||||||
|
'Comté (fiable, apprécié·e, un peu contrôlant·e)',
|
||||||
|
'Reblochon (réconfortant·e mais intense)',
|
||||||
|
'Camembert (classique mais imprévisible)',
|
||||||
|
'Roquefort (clivant·e, puissant·e, assumé·e)',
|
||||||
|
'Chèvre frais (léger·ère, sensible, un peu fragile)'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// STATE
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
let currentName = '';
|
||||||
|
let currentQ = 0;
|
||||||
|
let selectedChoice = null;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// SCREENS
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
function showScreen(id) {
|
||||||
|
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// NAME SELECTION
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
function renderNames(filter = '') {
|
||||||
|
const grid = document.getElementById('names-grid');
|
||||||
|
const filtered = filter
|
||||||
|
? ALL_NAMES.filter(n => n.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: ALL_NAMES;
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
grid.innerHTML = '<div class="no-results">Aucun prénom trouvé 😅</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = filtered.map(name =>
|
||||||
|
`<button class="name-btn" data-name="${name}">${name}</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
grid.querySelectorAll('.name-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
currentName = btn.dataset.name;
|
||||||
|
startQuiz();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('name-search').addEventListener('input', e => {
|
||||||
|
renderNames(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderNames();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// QUIZ
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
function startQuiz() {
|
||||||
|
currentQ = 0;
|
||||||
|
selectedChoice = null;
|
||||||
|
showScreen('screen-quiz');
|
||||||
|
renderQuestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuestion() {
|
||||||
|
const q = QUESTIONS[currentQ];
|
||||||
|
const total = QUESTIONS.length;
|
||||||
|
|
||||||
|
document.getElementById('progress-fill').style.width = `${(currentQ / total) * 100}%`;
|
||||||
|
document.getElementById('q-meta').textContent = `Question ${currentQ + 1} / ${total}`;
|
||||||
|
document.getElementById('q-text').textContent = q.text;
|
||||||
|
|
||||||
|
const letters = ['A','B','C','D','E'];
|
||||||
|
document.getElementById('q-choices').innerHTML = q.choices.map((c, i) =>
|
||||||
|
`<button class="choice-btn" data-idx="${i}">
|
||||||
|
<span class="letter">${letters[i]}</span>
|
||||||
|
<span>${c}</span>
|
||||||
|
</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
selectedChoice = null;
|
||||||
|
const btnNext = document.getElementById('btn-next');
|
||||||
|
btnNext.classList.remove('visible');
|
||||||
|
btnNext.textContent = currentQ < QUESTIONS.length - 1 ? 'Suivant →' : 'Révéler mon équipe ✨';
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
const choicesEl = document.getElementById('q-choices');
|
||||||
|
choicesEl.style.opacity = '0';
|
||||||
|
choicesEl.style.transform = 'translateY(14px)';
|
||||||
|
setTimeout(() => {
|
||||||
|
choicesEl.style.transition = 'opacity .3s, transform .3s';
|
||||||
|
choicesEl.style.opacity = '1';
|
||||||
|
choicesEl.style.transform = 'translateY(0)';
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
document.querySelectorAll('.choice-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
|
||||||
|
btn.classList.add('selected');
|
||||||
|
selectedChoice = parseInt(btn.dataset.idx);
|
||||||
|
btnNext.classList.add('visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-next').addEventListener('click', () => {
|
||||||
|
if (selectedChoice === null) return;
|
||||||
|
currentQ++;
|
||||||
|
if (currentQ < QUESTIONS.length) {
|
||||||
|
renderQuestion();
|
||||||
|
} else {
|
||||||
|
showResult();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// RESULT
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
function showResult() {
|
||||||
|
const teamName = NAME_TO_TEAM[currentName] || 'AXOLOTL';
|
||||||
|
const team = TEAMS[teamName];
|
||||||
|
|
||||||
|
document.getElementById('progress-fill').style.width = '100%';
|
||||||
|
|
||||||
|
const avatar = document.getElementById('r-avatar');
|
||||||
|
avatar.src = team.img;
|
||||||
|
avatar.alt = teamName;
|
||||||
|
const wrap = document.getElementById('r-avatar-wrap');
|
||||||
|
wrap.style.background = `conic-gradient(${team.color} 0%, ${team.color} 100%)`;
|
||||||
|
wrap.style.boxShadow = `0 0 28px ${team.color}44`;
|
||||||
|
|
||||||
|
document.getElementById('r-name').textContent = `Bienvenue, ${currentName} !`;
|
||||||
|
|
||||||
|
const badge = document.getElementById('r-badge');
|
||||||
|
badge.textContent = teamName;
|
||||||
|
badge.style.background = team.color + '22';
|
||||||
|
badge.style.border = `2px solid ${team.color}66`;
|
||||||
|
badge.style.color = team.color;
|
||||||
|
|
||||||
|
document.getElementById('r-desc').textContent = `"${team.desc}"`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
showScreen('screen-result');
|
||||||
|
launchConfetti(team.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// CONFETTI
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
function launchConfetti(mainColor) {
|
||||||
|
const container = document.getElementById('confetti');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const colors = [mainColor, '#fff', '#ffd166', '#c77dff', '#26d0ce', '#ff6b6b'];
|
||||||
|
const count = 80;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'confetto';
|
||||||
|
el.style.left = `${Math.random() * 100}vw`;
|
||||||
|
el.style.width = el.style.height = `${6 + Math.random() * 8}px`;
|
||||||
|
el.style.borderRadius = Math.random() > .5 ? '50%' : '2px';
|
||||||
|
el.style.background = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
el.style.opacity = (.6 + Math.random() * .4).toString();
|
||||||
|
const dur = 1.4 + Math.random() * 1.6;
|
||||||
|
const delay = Math.random() * .8;
|
||||||
|
el.style.animation = `confettiFall ${dur}s ${delay}s linear forwards`;
|
||||||
|
container.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => { container.innerHTML = ''; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// RESTART
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
document.getElementById('btn-restart').addEventListener('click', () => {
|
||||||
|
currentName = '';
|
||||||
|
document.getElementById('name-search').value = '';
|
||||||
|
renderNames();
|
||||||
|
showScreen('screen-name');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
apps/web/quizz-site/poule-de-soie.jpeg
Normal file
|
After Width: | Height: | Size: 554 KiB |
BIN
apps/web/quizz-site/ratel.jpeg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/web/quizz-site/saiga.jpeg
Normal file
|
After Width: | Height: | Size: 119 KiB |
1752
apps/web/site/app.js
Normal file
BIN
apps/web/site/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/site/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/site/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
17
apps/web/site/favicon.svg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
239
apps/web/site/index.html
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Metal from Europe</title>
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=UnifrakturCook:wght@700&family=Inter:wght@300;400;600;700;900&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Leaflet -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- MarkerCluster -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
|
||||||
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||||
|
|
||||||
|
<!-- Heatmap -->
|
||||||
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||||
|
|
||||||
|
<!-- noUiSlider (double slider timeline) -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.css">
|
||||||
|
<script src="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.js"></script>
|
||||||
|
|
||||||
|
<!-- GoatCounter pour les visites-->
|
||||||
|
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header id="topbar">
|
||||||
|
<button class="sidebar-toggle" id="sidebarToggle" type="button" aria-expanded="true" aria-controls="sidebar" title="Afficher / masquer les options">
|
||||||
|
<span class="icon">☰</span>
|
||||||
|
<span class="label">Options</span>
|
||||||
|
</button>
|
||||||
|
<div class="brand">Metal from Europe</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/MetalFromEurope" target="_blank" rel="noopener noreferrer" class="social-link" title="Suivre sur X/Twitter">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.instagram.com/metalfromeurope/" target="_blank" rel="noopener noreferrer" class="social-link" title="Suivre sur Instagram">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.metal-archives.com/" target="_blank" rel="noopener noreferrer" class="social-link ma-link" title="Visiter Metal Archives">
|
||||||
|
<span class="ma-text">Metal Archives</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" class="external-icon">
|
||||||
|
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<div class="sidebar-inner">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="search">
|
||||||
|
<input
|
||||||
|
id="q"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher (nom, genre, ville, pays)…"
|
||||||
|
aria-label="Rechercher des groupes"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<div class="search-suggestions" id="searchSuggestions"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<span class="chip"><span class="dot"></span><b id="count">0</b> affichés</span>
|
||||||
|
<span class="chip"><b id="total">0</b> total</span>
|
||||||
|
<span class="chip"><b id="unique">0</b> localités</span>
|
||||||
|
<span class="chip"><b id="geocoded">0</b> localisés</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controls-head">
|
||||||
|
<div class="controls-title">Vue</div>
|
||||||
|
<div class="controls-actions">
|
||||||
|
<button class="btn btn-compact" id="btnReset" type="button">Reset view</button>
|
||||||
|
<label class="switch" title="Activer la heatmap">
|
||||||
|
<input type="checkbox" id="toggleHeat" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
<span class="switch-label">Heatmap</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controls-head">
|
||||||
|
<div class="controls-title">Filtres</div>
|
||||||
|
<button class="btn btn-compact" id="btnNoLocation" type="button">
|
||||||
|
Sans coords <span class="badge" id="noLocCount">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Country -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Pays</div>
|
||||||
|
<button class="dropdown-trigger" id="countryToggle" type="button" aria-expanded="false" aria-controls="countrySelect">
|
||||||
|
<span class="label">Sélection</span>
|
||||||
|
<span class="summary" id="countrySummary">Tous</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="multiselect is-collapsed" id="countrySelect"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Statut</div>
|
||||||
|
<div class="chips" id="statusGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Macro genres -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Genres</div>
|
||||||
|
<div class="macro-grid" id="macroGenres"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre multiselect -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Sous-genres</div>
|
||||||
|
<button class="dropdown-trigger" id="genreToggle" type="button" aria-expanded="false" aria-controls="genreSelect">
|
||||||
|
<span class="label">Sélection</span>
|
||||||
|
<span class="summary" id="genreSummary">Tous</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="multiselect is-collapsed" id="genreSelect"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Themes -->
|
||||||
|
<div class="filter-box" id="themeBox" style="display:none;">
|
||||||
|
<div class="filter-title">Thèmes</div>
|
||||||
|
<button class="dropdown-trigger" id="themeToggle" type="button" aria-expanded="false" aria-controls="themeSelect">
|
||||||
|
<span class="label">Sélection</span>
|
||||||
|
<span class="summary" id="themeSummary">Tous</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="multiselect is-collapsed" id="themeSelect"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Timeline (année de formation)</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div id="yearSlider"></div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
<span class="chip"><b id="yearMin">—</b></span>
|
||||||
|
<span class="chip"><b id="yearMax">—</b></span>
|
||||||
|
<span class="chip"><b id="yearHint">toutes</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<div class="filter-box">
|
||||||
|
<div class="filter-title">Tri de la liste</div>
|
||||||
|
<select id="sortSelect" class="select" aria-label="Trier les groupes">
|
||||||
|
<option value="az">A → Z</option>
|
||||||
|
<option value="status">Par statut</option>
|
||||||
|
<option value="genre">Par genre</option>
|
||||||
|
<option value="country">Par pays</option>
|
||||||
|
<option value="year">Par année (récent → ancien)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="h">Liste</div>
|
||||||
|
<div class="sub" id="selectionHint">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" id="list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="mapWrap">
|
||||||
|
<div id="map" role="application" aria-label="Carte des groupes de Black Metal"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer id="legalBar">
|
||||||
|
<div class="legal-inner">
|
||||||
|
<button class="legal-link" id="openLegal" type="button">Mentions légales</button>
|
||||||
|
<span class="legal-dot">•</span>
|
||||||
|
<button class="legal-link" id="openFaq" type="button">FAQ</button>
|
||||||
|
|
||||||
|
<span class="legal-dot">•</span>
|
||||||
|
|
||||||
|
<span class="legal-stats" aria-label="Statistiques de visites">
|
||||||
|
<span class="legal-stat">Visites <b id="gcTotal">—</b></span>
|
||||||
|
<span class="legal-dot">/</span>
|
||||||
|
<span class="legal-stat">Ce mois <b id="gcMonth">—</b></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Modal: no location -->
|
||||||
|
<div class="modal-backdrop" id="modalBackdrop" aria-hidden="true">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="h" id="modalTitle">Sans coords</div>
|
||||||
|
<div class="x" id="modalClose" title="Fermer" aria-label="Fermer la fenêtre">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: legal / faq -->
|
||||||
|
<div class="modal-backdrop" id="infoBackdrop" aria-hidden="true">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="infoTitle">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="h" id="infoTitle">Infos</div>
|
||||||
|
<div class="x" id="infoClose" title="Fermer" aria-label="Fermer la fenêtre">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="infoBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
apps/web/site/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "Metal From Eu",
|
||||||
|
"short_name": "MetalEU",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
1346
apps/web/site/styles.css
Normal file
BIN
apps/web/site/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/site/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
9
apps/worker/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM mcr.microsoft.com/playwright/python:v1.49.0-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
CMD ["python", "src/run.py"]
|
||||||
4
apps/worker/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
playwright==1.49.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
requests==2.32.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
122
apps/worker/src/run.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import os, re, json
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
MA_LIST_URL = os.getenv("MA_LIST_URL", "https://www.metal-archives.com/lists/FR")
|
||||||
|
DB_DSN = os.getenv("DB_DSN")
|
||||||
|
STATE_PATH = os.getenv("MA_STATE_PATH", "/app/ma_state.json")
|
||||||
|
|
||||||
|
def db_conn():
|
||||||
|
return psycopg2.connect(DB_DSN)
|
||||||
|
|
||||||
|
def upsert_band(cur, b):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO bands (ma_id, name, country, location_text, status, genre, data)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)
|
||||||
|
ON CONFLICT (ma_id) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,
|
||||||
|
country=EXCLUDED.country,
|
||||||
|
location_text=EXCLUDED.location_text,
|
||||||
|
status=EXCLUDED.status,
|
||||||
|
genre=EXCLUDED.genre,
|
||||||
|
data=EXCLUDED.data
|
||||||
|
""",
|
||||||
|
(b["ma_id"], b["name"], b.get("country"), b.get("location_text"),
|
||||||
|
b.get("status"), b.get("genre"), b.get("data_json","{}"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_ma_id(url: str):
|
||||||
|
m = re.search(r"/bands/[^/]+/(\d+)", (url or ""))
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
def strip_html(s: str) -> str:
|
||||||
|
return re.sub(r"<[^>]+>", "", (s or "")).strip()
|
||||||
|
|
||||||
|
def parse_row(row):
|
||||||
|
# row = [ '<a href=".../bands/Name/123">Name</a>', 'Genre', 'Location', 'Status' ]
|
||||||
|
if not isinstance(row, list) or len(row) < 2:
|
||||||
|
return None
|
||||||
|
name_html = str(row[0])
|
||||||
|
m = re.search(r'href="([^"]+)"', name_html)
|
||||||
|
url = m.group(1) if m else None
|
||||||
|
name = strip_html(name_html)
|
||||||
|
genre = strip_html(str(row[1])) if len(row) > 1 else ""
|
||||||
|
loc = strip_html(str(row[2])) if len(row) > 2 else ""
|
||||||
|
status = strip_html(str(row[3])) if len(row) > 3 else ""
|
||||||
|
return name, url, genre, loc, status
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not DB_DSN:
|
||||||
|
raise SystemExit("DB_DSN not set")
|
||||||
|
|
||||||
|
print(f"[worker] MA list: {MA_LIST_URL}")
|
||||||
|
print(f"[worker] using storage_state: {STATE_PATH}")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
# charge cookies / storage state Cloudflare
|
||||||
|
context = browser.new_context(storage_state=STATE_PATH)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
# Debug minimal des XHR
|
||||||
|
xhr = []
|
||||||
|
def on_response(resp):
|
||||||
|
if resp.request.resource_type == "xhr":
|
||||||
|
u = resp.url
|
||||||
|
if "/browse/ajax-country/" in u or "/user/session" in u or "/cdn-cgi/" in u:
|
||||||
|
xhr.append(f"{resp.status} {u}")
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
page.goto(MA_LIST_URL, wait_until="domcontentloaded")
|
||||||
|
page.wait_for_selector("table.display", timeout=60_000)
|
||||||
|
|
||||||
|
# Attendre la réponse DataTables (doit passer en 200 si cookie OK)
|
||||||
|
def ok_ajax(resp):
|
||||||
|
return ("/browse/ajax-country/" in resp.url) and resp.status == 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
with page.expect_response(ok_ajax, timeout=60_000) as resp_info:
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
dt = resp_info.value
|
||||||
|
except Exception:
|
||||||
|
print("[worker] ERROR: did not get 200 ajax-country within 60s")
|
||||||
|
print("[worker] xhr seen:", xhr[:20])
|
||||||
|
raise
|
||||||
|
|
||||||
|
j = dt.json()
|
||||||
|
rows = j.get("aaData") or j.get("data") or []
|
||||||
|
print(f"[worker] ajax ok. rows={len(rows)}")
|
||||||
|
|
||||||
|
bands = []
|
||||||
|
for row in rows[:5]:
|
||||||
|
parsed = parse_row(row)
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
name, url, genre, location, status = parsed
|
||||||
|
ma_id = parse_ma_id(url)
|
||||||
|
if not ma_id:
|
||||||
|
continue
|
||||||
|
bands.append({
|
||||||
|
"ma_id": ma_id,
|
||||||
|
"name": name,
|
||||||
|
"country": "France",
|
||||||
|
"genre": genre,
|
||||||
|
"location_text": location,
|
||||||
|
"status": status or None,
|
||||||
|
"data_json": json.dumps({"source":"ma_list_fr","url":url}),
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for b in bands:
|
||||||
|
upsert_band(cur, b)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"[worker] inserted/updated: {len(bands)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
infra/.env
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
BM_DB_PASSWORD=Astatke2510!db
|
||||||
|
BM_DOMAIN=bm.nicolasfryder.ovh
|
||||||
|
NOMINATIM_EMAIL=tonmail@tondomaine.tld
|
||||||
|
NOMINATIM_USER_AGENT=bm-map/0.1 (contact: tonmail@tondomaine.tld)
|
||||||
|
DATABASE_URL=postgresql://bm:Astatke2510!db@db:5432/bm
|
||||||
|
|
||||||
|
# Recommandé (code-server sous-domaine dédié)
|
||||||
|
BM_CODE_DOMAIN=code.bm.nicolasfryder.ovh
|
||||||
|
CODESERVER_PASSWORD=Astatke2510!cod
|
||||||
|
|
||||||
|
TZ=Europe/Paris
|
||||||
|
PUID=1001
|
||||||
|
PGID=1001
|
||||||
|
BM_IMPORT_TOKEN=c4b99106fcd09badce25810e974ec450af127a4725cb774f03b72217a04eb338
|
||||||
|
PGADMIN_EMAIL=nicolasfryder@gmail.com
|
||||||
|
PGADMIN_PASSWORD=Astatke2510!
|
||||||
|
After Width: | Height: | Size: 534 B |
13
infra/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
BM_DB_PASSWORD=
|
||||||
|
DATABASE_URL=postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
NOMINATIM_EMAIL=
|
||||||
|
NOMINATIM_USER_AGENT=bm-map/0.1
|
||||||
|
BM_IMPORT_TOKEN=
|
||||||
|
PGADMIN_EMAIL=
|
||||||
|
PGADMIN_PASSWORD=
|
||||||
|
CODESERVER_PASSWORD=
|
||||||
|
BM_DOMAIN=bm.nicolasfryder.ovh
|
||||||
|
BM_CODE_DOMAIN=code.bm.nicolasfryder.ovh
|
||||||
|
TZ=Europe/Paris
|
||||||
|
PUID=1001
|
||||||
|
PGID=1001
|
||||||
|
After Width: | Height: | Size: 302 B |
27
infra/bootstrap_ma.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
URL = "https://www.metal-archives.com/lists/FR"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=False) # IMPORTANT: visible
|
||||||
|
context = browser.new_context()
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
print("Ouvre la page. Si Cloudflare challenge apparaît, résous-le dans la fenêtre.")
|
||||||
|
page.goto(URL, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
print("J'attends qu'un appel /browse/ajax-country/ passe en 200 (preuve que l'accès est OK)...")
|
||||||
|
|
||||||
|
def ok_ajax(resp):
|
||||||
|
return ("/browse/ajax-country/" in resp.url) and resp.status == 200
|
||||||
|
|
||||||
|
# Attends que DataTables charge réellement (XHR 200)
|
||||||
|
page.wait_for_response(ok_ajax, timeout=180_000)
|
||||||
|
|
||||||
|
context.storage_state(path="ma_state.json")
|
||||||
|
print("✅ ma_state.json sauvegardé. Tu peux fermer le navigateur.")
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
152
infra/docker-compose.yml
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bm
|
||||||
|
POSTGRES_USER: bm
|
||||||
|
POSTGRES_PASSWORD: ${BM_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- bm_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bm -d bm"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- bm_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Pre-build avec Dockerfile
|
||||||
|
geocoder-enqueue:
|
||||||
|
build:
|
||||||
|
context: ../apps/geocoder
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
GEOCODE_ENQUEUE_BATCH: "90000"
|
||||||
|
command: ["python", "src/enqueue.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Pre-build avec Dockerfile
|
||||||
|
geocoder-worker:
|
||||||
|
build:
|
||||||
|
context: ../apps/geocoder
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
NOMINATIM_BASE: "https://nominatim.openstreetmap.org"
|
||||||
|
NOMINATIM_EMAIL: ${NOMINATIM_EMAIL}
|
||||||
|
NOMINATIM_USER_AGENT: ${NOMINATIM_USER_AGENT}
|
||||||
|
NOMINATIM_MIN_DELAY: "1.05"
|
||||||
|
NOMINATIM_JITTER: "0.35"
|
||||||
|
GEOCODE_MAX_PER_RUN: "5000"
|
||||||
|
command: ["python", "src/worker.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:8
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
||||||
|
PGADMIN_CONFIG_SERVER_MODE: "True"
|
||||||
|
volumes:
|
||||||
|
- pgadmin_data:/var/lib/pgadmin
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.pgadmin.entrypoints=websecure
|
||||||
|
- traefik.http.routers.pgadmin.tls=true
|
||||||
|
- traefik.http.routers.pgadmin.tls.certresolver=le
|
||||||
|
- traefik.http.services.pgadmin.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../apps/api
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./overrides/api/src/server.js:/app/src/server.js:ro
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-api.tls=true
|
||||||
|
- traefik.http.routers.bm-api.tls.certresolver=le
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Double auth + rate limiting
|
||||||
|
code:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
environment:
|
||||||
|
PUID: "1001"
|
||||||
|
PGID: "1001"
|
||||||
|
TZ: "Europe/Paris"
|
||||||
|
PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
SUDO_PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
DEFAULT_WORKSPACE: /workspace
|
||||||
|
volumes:
|
||||||
|
- bm_codeserver_config:/config
|
||||||
|
- /srv/stacks/bm:/workspace
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-code.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-code.tls=true
|
||||||
|
# ✅ AJOUTÉ : Double auth + rate limiting
|
||||||
|
- traefik.http.routers.bm-code.middlewares=code-auth@file,code-ratelimit@file
|
||||||
|
- traefik.http.services.bm-code.loadbalancer.server.port=8443
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../apps/worker
|
||||||
|
environment:
|
||||||
|
DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
MA_LIST_URL: https://www.metal-archives.com/lists/FR
|
||||||
|
MA_STATE_PATH: /app/ma_state.json
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_net:
|
||||||
|
external: true
|
||||||
|
name: stack-vps_admin
|
||||||
|
bm_internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bm_pg_data:
|
||||||
|
bm_redis_data:
|
||||||
|
bm_codeserver_config:
|
||||||
|
pgadmin_data:
|
||||||
158
infra/docker-compose.yml.backup
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bm
|
||||||
|
POSTGRES_USER: bm
|
||||||
|
POSTGRES_PASSWORD: ${BM_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- bm_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bm -d bm"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- bm_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
geocoder-enqueue:
|
||||||
|
image: python:3.12-slim
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ../apps/geocoder:/app:ro
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
GEOCODE_ENQUEUE_BATCH: "90000"
|
||||||
|
command: ["sh", "-lc", "pip install -r requirements.txt && python src/enqueue.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
geocoder-worker:
|
||||||
|
image: python:3.12-slim
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ../apps/geocoder:/app:ro
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
NOMINATIM_BASE: "https://nominatim.openstreetmap.org"
|
||||||
|
NOMINATIM_EMAIL: ${NOMINATIM_EMAIL}
|
||||||
|
NOMINATIM_USER_AGENT: ${NOMINATIM_USER_AGENT}
|
||||||
|
NOMINATIM_MIN_DELAY: "1.05"
|
||||||
|
NOMINATIM_JITTER: "0.35"
|
||||||
|
GEOCODE_MAX_PER_RUN: "5000"
|
||||||
|
command: ["sh", "-lc", "pip install -r requirements.txt && python src/worker.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:8
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
||||||
|
PGADMIN_CONFIG_SERVER_MODE: "True"
|
||||||
|
volumes:
|
||||||
|
- pgadmin_data:/var/lib/pgadmin
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.pgadmin.entrypoints=websecure
|
||||||
|
- traefik.http.routers.pgadmin.tls=true
|
||||||
|
- traefik.http.routers.pgadmin.tls.certresolver=le
|
||||||
|
- traefik.http.routers.pgadmin.middlewares=auth@file
|
||||||
|
- traefik.http.services.pgadmin.loadbalancer.server.port=80
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../apps/api
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./overrides/api/src/server.js:/app/src/server.js:ro
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-api.tls=true
|
||||||
|
- traefik.http.routers.bm-api.tls.certresolver=le
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowOriginList=https://metalfrom.eu,https://www.metalfrom.eu
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowHeaders=*
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowCredentials=true
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.addVaryHeader=true
|
||||||
|
|
||||||
|
- traefik.http.routers.bm-api.middlewares=bm-cors@docker
|
||||||
|
|
||||||
|
code:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
environment:
|
||||||
|
PUID: "1001"
|
||||||
|
PGID: "1001"
|
||||||
|
TZ: "Europe/Paris"
|
||||||
|
PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
SUDO_PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
DEFAULT_WORKSPACE: /workspace
|
||||||
|
volumes:
|
||||||
|
- bm_codeserver_config:/config
|
||||||
|
- /srv/stacks/bm:/workspace
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-code.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-code.tls=true
|
||||||
|
- traefik.http.services.bm-code.loadbalancer.server.port=8443
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../apps/worker
|
||||||
|
environment:
|
||||||
|
DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
MA_LIST_URL: https://www.metal-archives.com/lists/FR
|
||||||
|
MA_STATE_PATH: /app/ma_state.json
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_net:
|
||||||
|
external: true
|
||||||
|
name: stack-vps_admin
|
||||||
|
bm_internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bm_pg_data:
|
||||||
|
bm_redis_data:
|
||||||
|
bm_codeserver_config:
|
||||||
|
pgadmin_data:
|
||||||
|
|
||||||
42
infra/init.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bands (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ma_id BIGINT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT,
|
||||||
|
location_text TEXT,
|
||||||
|
status TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
formed_year INT,
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lon DOUBLE PRECISION,
|
||||||
|
geom GEOGRAPHY(Point, 4326),
|
||||||
|
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keep geom in sync if lat/lon set
|
||||||
|
CREATE OR REPLACE FUNCTION bands_set_geom() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.lat IS NOT NULL AND NEW.lon IS NOT NULL THEN
|
||||||
|
NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lon, NEW.lat), 4326)::geography;
|
||||||
|
ELSE
|
||||||
|
NEW.geom := NULL;
|
||||||
|
END IF;
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_bands_set_geom ON bands;
|
||||||
|
CREATE TRIGGER trg_bands_set_geom
|
||||||
|
BEFORE INSERT OR UPDATE OF lat, lon ON bands
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bands_set_geom();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bands_country ON bands (country);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bands_status ON bands (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bands_genre ON bands (genre);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bands_geom ON bands USING GIST (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bands_data_gin ON bands USING GIN (data);
|
||||||
718
infra/overrides/api/src/server.js
Normal file
|
|
@ -0,0 +1,718 @@
|
||||||
|
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" });
|
||||||
521
infra/overrides/api/src/server_a.js
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
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" });
|
||||||
164
infra/temp
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bm
|
||||||
|
POSTGRES_USER: bm
|
||||||
|
POSTGRES_PASSWORD: ${BM_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- bm_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bm -d bm"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- bm_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Pre-build avec Dockerfile
|
||||||
|
geocoder-enqueue:
|
||||||
|
build:
|
||||||
|
context: ../apps/geocoder
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
GEOCODE_ENQUEUE_BATCH: "90000"
|
||||||
|
command: ["python", "src/enqueue.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Pre-build avec Dockerfile
|
||||||
|
geocoder-worker:
|
||||||
|
build:
|
||||||
|
context: ../apps/geocoder
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
NOMINATIM_BASE: "https://nominatim.openstreetmap.org"
|
||||||
|
NOMINATIM_EMAIL: ${NOMINATIM_EMAIL}
|
||||||
|
NOMINATIM_USER_AGENT: ${NOMINATIM_USER_AGENT}
|
||||||
|
NOMINATIM_MIN_DELAY: "1.05"
|
||||||
|
NOMINATIM_JITTER: "0.35"
|
||||||
|
GEOCODE_MAX_PER_RUN: "5000"
|
||||||
|
command: ["python", "src/worker.py"]
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Double auth + rate limiting
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:8
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
||||||
|
PGADMIN_CONFIG_SERVER_MODE: "True"
|
||||||
|
volumes:
|
||||||
|
- pgadmin_data:/var/lib/pgadmin
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.pgadmin.entrypoints=websecure
|
||||||
|
- traefik.http.routers.pgadmin.tls=true
|
||||||
|
- traefik.http.routers.pgadmin.tls.certresolver=le
|
||||||
|
# ✅ CHANGÉ : Utilise le nouveau middleware avec rate limiting
|
||||||
|
- traefik.http.routers.pgadmin.middlewares=pgadmin-auth@file,pgadmin-ratelimit@file
|
||||||
|
- traefik.http.services.pgadmin.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../apps/api
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./overrides/api/src/server.js:/app/src/server.js:ro
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-api.tls=true
|
||||||
|
- traefik.http.routers.bm-api.tls.certresolver=le
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowOriginList=https://metalfrom.eu,https://www.metalfrom.eu
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowHeaders=*
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.accessControlAllowCredentials=true
|
||||||
|
- traefik.http.middlewares.bm-cors.headers.addVaryHeader=true
|
||||||
|
|
||||||
|
- traefik.http.routers.bm-api.middlewares=bm-cors@docker
|
||||||
|
|
||||||
|
# ✅ SÉCURISÉ : Double auth + rate limiting
|
||||||
|
code:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
environment:
|
||||||
|
PUID: "1001"
|
||||||
|
PGID: "1001"
|
||||||
|
TZ: "Europe/Paris"
|
||||||
|
PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
SUDO_PASSWORD: ${CODESERVER_PASSWORD}
|
||||||
|
DEFAULT_WORKSPACE: /workspace
|
||||||
|
volumes:
|
||||||
|
- bm_codeserver_config:/config
|
||||||
|
- /srv/stacks/bm:/workspace
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
- traefik_net
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=stack-vps_admin
|
||||||
|
- traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`)
|
||||||
|
- traefik.http.routers.bm-code.entrypoints=websecure
|
||||||
|
- traefik.http.routers.bm-code.tls=true
|
||||||
|
# ✅ AJOUTÉ : Double auth + rate limiting
|
||||||
|
- traefik.http.routers.bm-code.middlewares=code-auth@file,code-ratelimit@file
|
||||||
|
- traefik.http.services.bm-code.loadbalancer.server.port=8443
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../apps/worker
|
||||||
|
environment:
|
||||||
|
DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm
|
||||||
|
MA_LIST_URL: https://www.metal-archives.com/lists/FR
|
||||||
|
MA_STATE_PATH: /app/ma_state.json
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro
|
||||||
|
networks:
|
||||||
|
- bm_internal
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_net:
|
||||||
|
external: true
|
||||||
|
name: stack-vps_admin
|
||||||
|
bm_internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bm_pg_data:
|
||||||
|
bm_redis_data:
|
||||||
|
bm_codeserver_config:
|
||||||
|
pgadmin_data:
|
||||||