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.
This commit is contained in:
Nicolas Fryder 2026-06-27 08:53:39 +00:00
commit d4e1bf2e15
38 changed files with 7033 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -0,0 +1,2 @@
psycopg2-binary==2.9.9
requests==2.32.3

View 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
View 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()

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

1752
apps/web/site/app.js Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
apps/web/site/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

17
apps/web/site/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

239
apps/web/site/index.html Normal file
View 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>

View 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

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

9
apps/worker/Dockerfile Normal file
View 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"]

View 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
View 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
View 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
View 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
View 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
View 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:

View 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
View 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);

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

View 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
View 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: