fix(admin): XSS stocké dans l'activité, timeout des requêtes, déblocage manuel

activitySummary() injectait crawl_run.error en innerHTML sans échappement. Ce
message provient d'exceptions du crawler, qui contiennent du contenu scrapé sur
Metal Archives — donc influençable depuis l'extérieur.

api() n'avait aucun timeout : une API muette (pool DB saturé, upstream nginx
silencieux) laissait la vue bloquée sur « Chargement… » indéfiniment.

reset-errors vise désormais aussi les lignes coincées en 'processing', en
forçage manuel du mécanisme d'auto-réparation du worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-20 16:44:07 +02:00
parent cc6be5e341
commit da24d2a8a1
2 changed files with 21 additions and 6 deletions

View file

@ -33,11 +33,21 @@ const state = {
// API // API
// ------------------------------------------------------------------ // ------------------------------------------------------------------
async function api(path, opts = {}) { async function api(path, opts = {}) {
const res = await fetch(path, { // Timeout explicite : sans lui, une API qui ne répond jamais (pool DB saturé,
// upstream nginx muet) laisse la vue bloquée sur « Chargement… » indéfiniment.
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), opts.timeoutMs || 20000);
let res;
try {
res = await fetch(path, {
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
signal: ctrl.signal,
...opts, ...opts,
}); });
} finally {
clearTimeout(t);
}
if (res.status === 401) { if (res.status === 401) {
showLogin(); showLogin();
throw new Error("unauthorized"); throw new Error("unauthorized");
@ -1168,7 +1178,7 @@ function activitySummary(row) {
if (sum.bands_new) parts.push(`${fmtNum(sum.bands_new)} nouveaux`); if (sum.bands_new) parts.push(`${fmtNum(sum.bands_new)} nouveaux`);
if (sum.bands_updated) parts.push(`${fmtNum(sum.bands_updated)} MAJ`); if (sum.bands_updated) parts.push(`${fmtNum(sum.bands_updated)} MAJ`);
if (sum.bands_enriched) parts.push(`${fmtNum(sum.bands_enriched)} enrichis`); if (sum.bands_enriched) parts.push(`${fmtNum(sum.bands_enriched)} enrichis`);
if (sum.error) parts.push(`erreur : ${sum.error}`); if (sum.error) parts.push(`erreur : ${esc(sum.error)}`);
return parts.join(" · ") || "—"; return parts.join(" · ") || "—";
} }
return `${esc(sum.target_table || "")} #${sum.target_id ?? ""}`; return `${esc(sum.target_table || "")} #${sum.target_id ?? ""}`;

View file

@ -872,6 +872,10 @@ export default async function adminRoutes(fastify, opts) {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Géocodage — actions sur band_locations (nouveau pipeline) // Géocodage — actions sur band_locations (nouveau pipeline)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Débloque les lignes coincées en 'processing' (worker tué en plein
// traitement) en plus de l'ancien statut 'error' (legacy). Le worker se répare
// aussi tout seul au bout de GEOCODE_STUCK_PROCESSING_MIN ; ce bouton est un
// forçage manuel pour ne pas attendre.
fastify.post("/admin/api/locations/reset-errors", async (req, reply) => { fastify.post("/admin/api/locations/reset-errors", async (req, reply) => {
try { try {
const r = await pool.query(` const r = await pool.query(`
@ -879,6 +883,7 @@ export default async function adminRoutes(fastify, opts) {
SET geocode_status='queued', geocode_tries_geo=0, SET geocode_status='queued', geocode_tries_geo=0,
geocode_error=NULL, geocode_next_at=now(), updated_at=now() geocode_error=NULL, geocode_next_at=now(), updated_at=now()
WHERE geocode_status = 'error' WHERE geocode_status = 'error'
OR (geocode_status = 'processing' AND updated_at < now() - interval '5 minutes')
`); `);
const count = r.rowCount; const count = r.rowCount;
await pool.query( await pool.query(