Afficher l'IP client (x-forwarded-for) dans les logs des requêtes API

This commit is contained in:
Antoine Jacquin
2026-09-14 19:30:08 +02:00
parent df462ea86e
commit cea671c0ac
4 changed files with 219 additions and 21 deletions

View File

@ -37,7 +37,7 @@
### Module structure & data flow ### Module structure & data flow
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map) - `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls. - `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation). - `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation). `/api/presets` (GET/POST/DELETE) shares layer presets between browsers (`output/.presets.json`, localStorage echo in the interface as fallback).
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress. - `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp. - `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.

View File

@ -1389,6 +1389,11 @@ const STATS = {stats_json};
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */ _APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
:root { :root {
/* Le color-scheme impose au navigateur le rendu sombre des contrôles
natifs (popup des <select>, barres de défilement) : sans cela, le
menu déroulant des préconfigurations s'affiche en thème clair sur
une interface sombre. */
color-scheme: dark;
--bg: #0b0e13; --bg: #0b0e13;
--panel: rgba(16, 20, 27, 0.93); --panel: rgba(16, 20, 27, 0.93);
--panel-border: rgba(255, 255, 255, 0.09); --panel-border: rgba(255, 255, 255, 0.09);
@ -1465,6 +1470,10 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
border: 1px solid var(--panel-border); border-radius: 6px; border: 1px solid var(--panel-border); border-radius: 6px;
padding: 3px 6px; font-size: 12px; cursor: pointer; padding: 3px 6px; font-size: 12px; cursor: pointer;
} }
.gen-select:disabled { opacity: 0.4; cursor: not-allowed; }
/* Options du menu déroulant : fallback pour les navigateurs sans
color-scheme (le popup natif hérite de ces couleurs). */
.gen-select option { background-color: #10141b; color: var(--text); }
/* --- Barre latérale (gauche, pleine hauteur) --- */ /* --- Barre latérale (gauche, pleine hauteur) --- */
#sidebar { #sidebar {
@ -2246,10 +2255,49 @@ document.getElementById('osmRow').querySelector('.layer-name').title =
// visibles, leurs niveaux de transparence (opacités des // visibles, leurs niveaux de transparence (opacités des
// visualisations + fond de carte OSM) et l'ordre de pile // visualisations + fond de carte OSM) et l'ordre de pile
// (bas → haut). Les presets intégrés couvrent les couches du // (bas → haut). Les presets intégrés couvrent les couches du
// panneau ; les presets personnels (persistés en localStorage) // panneau ; les presets personnels sont stockés sur le serveur
// se suppriment depuis la liste. // (/api/presets, partagés entre tous les navigateurs connectés)
// et en écho dans le localStorage du navigateur (repli si l'interface
// est servie sans webapp).
// ============================================================ // ============================================================
const PRESETS_LS = 'lidarPresets_v1'; const PRESETS_LS = 'lidarPresets_v1';
const PRESETS_URL = '/api/presets';
// Écho local des presets : s'il n'y a pas de webapp (serveur statique),
// ils ne vivent que dans le localStorage du navigateur.
let customPresets = (() => {
try {
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
return Array.isArray(p) ? p : [];
} catch (e) { return []; }
})();
function persistCustomPresets() {
try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ }
}
// Les presets stockés sur le serveur sont partagés entre tous les
// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux
// déjà en local (même id = la version serveur l'emporte). Webapp
// injoignable : la liste locale est conservée telle quelle.
function upsertCustomPreset(p) {
const i = customPresets.findIndex(x => x.id === p.id);
if (i === -1) customPresets.push(p); else customPresets[i] = p;
}
function removeCustomPreset(id) {
customPresets = customPresets.filter(p => p.id !== id);
}
async function syncCustomPresets() {
try {
const r = await fetch(PRESETS_URL, { cache: 'no-store' });
if (!r.ok) return;
const data = await r.json();
const server = Array.isArray(data.presets) ? data.presets : [];
const byId = new Map(customPresets.map(p => [p.id, p]));
for (const p of server) if (p && p.id) byId.set(p.id, p);
customPresets = [...byId.values()];
persistCustomPresets();
renderPresetSelect(presetSelectEl.value);
} catch (e) { /* pas de webapp : presets locaux uniquement */ }
}
// Les presets portent les niveaux de transparence : opacité de chaque // Les presets portent les niveaux de transparence : opacité de chaque
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre). // visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
const BUILTIN_PRESETS = [ const BUILTIN_PRESETS = [
@ -2270,16 +2318,6 @@ const BUILTIN_PRESETS = [
osm: { on: true, opacity: 0.85, dark: true } }, osm: { on: true, opacity: 0.85, dark: true } },
]; ];
function loadCustomPresets() {
try {
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
return Array.isArray(p) ? p : [];
} catch (e) { return []; }
}
function saveCustomPresets(list) {
try { localStorage.setItem(PRESETS_LS, JSON.stringify(list)); } catch (e) { /* privé */ }
}
// Niveaux de transparence d'un preset, lisibles dans la liste // Niveaux de transparence d'un preset, lisibles dans la liste
// (opacité de chaque visualisation + fond de carte OSM). // (opacité de chaque visualisation + fond de carte OSM).
function presetDetail(p) { function presetDetail(p) {
@ -2331,7 +2369,7 @@ function renderPresetSelect(selectedValue) {
o.textContent = p.label + ' — ' + presetDetail(p); o.textContent = p.label + ' — ' + presetDetail(p);
presetSelectEl.appendChild(o); presetSelectEl.appendChild(o);
} }
const customs = loadCustomPresets(); const customs = customPresets;
if (customs.length) { if (customs.length) {
const g = document.createElement('optgroup'); const g = document.createElement('optgroup');
g.label = 'Mes presets'; g.label = 'Mes presets';
@ -2352,7 +2390,7 @@ presetSelectEl.addEventListener('change', () => {
if (!v) return; if (!v) return;
let p = null; let p = null;
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8)); if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
else if (v.startsWith('custom:')) p = loadCustomPresets().find(x => x.id === v.slice(7)); else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7));
if (p) applyPreset(p); if (p) applyPreset(p);
}); });
@ -2369,23 +2407,34 @@ presetSaveEl.addEventListener('click', () => {
// Niveaux de transparence du fond de carte // Niveaux de transparence du fond de carte
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark }, osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
}; };
const customs = loadCustomPresets(); upsertCustomPreset(p);
customs.push(p); persistCustomPresets();
saveCustomPresets(customs);
renderPresetSelect('custom:' + p.id); renderPresetSelect('custom:' + p.id);
// Persistance serveur (partagée entre navigateurs) ; en échec
// (pas de webapp), le preset reste dans le stockage local.
fetch(PRESETS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(p),
}).then(r => {
if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status);
}).catch(() => {});
}); });
presetDeleteEl.addEventListener('click', () => { presetDeleteEl.addEventListener('click', () => {
const v = presetSelectEl.value; const v = presetSelectEl.value;
if (!v.startsWith('custom:')) return; if (!v.startsWith('custom:')) return;
if (!confirm('Supprimer ce preset ?')) return; if (!confirm('Supprimer ce preset ?')) return;
saveCustomPresets(loadCustomPresets().filter(p => p.id !== v.slice(7))); const id = v.slice(7);
removeCustomPreset(id);
persistCustomPresets();
renderPresetSelect(''); renderPresetSelect('');
fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' })
.catch(() => {});
}); });
renderPresetSelect(); renderPresetSelect();
syncCustomPresets();
// Barre latérale : repli + onglets (Couches / Génération)
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
function setSidebarCollapsed(collapsed) { function setSidebarCollapsed(collapsed) {
sidebar.classList.toggle('collapsed', collapsed); sidebar.classList.toggle('collapsed', collapsed);

View File

@ -111,6 +111,45 @@ def test_preview_rejects_unknown_viz():
assert "inconnu" in e.detail assert "inconnu" in e.detail
def test_preset_sanitize_and_upsert(tmp_path, monkeypatch):
"""Presets de couches : valeurs assainies, remplacées par identifiant."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
req = webapp.PresetRequest(id="Relief v2!", label=" Reliefs ",
on=["slope", "aspect"],
opacity={"slope": 0.5, "aspect": "0.8", "lrm": "x"},
osm={"on": True, "opacity": 1.5, "dark": True})
p = webapp._sanitize_preset(req)
assert p["id"] == "reliefv2"
assert p["label"] == "Reliefs"
assert p["opacity"] == {"slope": 0.5, "aspect": 0.8}
assert p["osm"] == {"on": True, "opacity": 1.0, "dark": True}
webapp.upsert_preset(req)
assert [x["label"] for x in webapp.list_presets()["presets"]] == ["Reliefs"]
# Même identifiant : remplacement, pas de doublon
webapp.upsert_preset(webapp.PresetRequest(id="reliefv2", label="Reliefs v2",
on=["slope"], opacity={}))
got = webapp.list_presets()["presets"]
assert len(got) == 1 and got[0]["label"] == "Reliefs v2"
# Suppression inconnue : 404
from fastapi import HTTPException
webapp.delete_preset(got[0]["id"])
assert webapp.list_presets()["presets"] == []
try:
webapp.delete_preset("reliefv2")
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 404
def test_preset_file_corrupted_is_ignored(tmp_path, monkeypatch):
"""Un fichier de presets corrompu est ignoré (liste vide, pas d'exception)."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
webapp.PRESETS_FILE.write_text("{pas du json", encoding="utf-8")
assert webapp._load_presets() == []
def test_build_command_resolutions_match_completeness(): def test_build_command_resolutions_match_completeness():
"""La commande -r et la détection de complétude utilisent les mêmes résolutions.""" """La commande -r et la détection de complétude utilisent les mêmes résolutions."""
from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command

View File

@ -37,6 +37,12 @@ le téléchargement IGN). Endpoints :
fichier en téléchargement. Marche aussi sur la webapp fichier en téléchargement. Marche aussi sur la webapp
légère : les tuiles viennent du cache local, aucune légère : les tuiles viennent du cache local, aucune
délégation à la machine de traitement. délégation à la machine de traitement.
GET /api/presets → presets de couches personnalisés (couches visibles,
opacités, ordre, fond OSM) stockés sur le serveur et
partagés par tous les navigateurs connectés
POST /api/presets → créer/remplacer un preset de couches (même identifiant
= remplacement)
DELETE /api/presets/{id} → supprimer un preset de couches
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles, Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
/visualisations, /DTM. /visualisations, /DTM.
@ -128,6 +134,17 @@ else:
logger.info("Backend de génération des tuiles : local — consultation " logger.info("Backend de génération des tuiles : local — consultation "
"autonome du cache, sans LIDAR_GENERATION_URL") "autonome du cache, sans LIDAR_GENERATION_URL")
@app.middleware("http")
async def _log_client_ip(request: Request, call_next):
xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
peer = request.client.host if request.client else None
ip = xff or peer
path = request.url.path
# Ne logguer que les requêtes API (pas les fichiers statiques)
if path.startswith("/api/") or path == "/":
logger.info(f"{request.method} {path} — client : {ip or '?'}")
return await call_next(request)
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur # assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
# statique soit actif même avant la première génération de l'index. # statique soit actif même avant la première génération de l'index.
_assets_dir = OUTPUT_DIR / "assets" _assets_dir = OUTPUT_DIR / "assets"
@ -524,6 +541,99 @@ def _load_job_state():
_load_job_state() _load_job_state()
# --- Presets de couches (partagés entre navigateurs) --------------------------
# Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de
# couches visibles + opacités + ordre de pile + fond OSM, créé depuis
# l'interface (bouton 💾) et partagé par tous les navigateurs connectés à
# cette webapp (y compris le Pi en mode deux machines). Le localStorage du
# navigateur reste le repli quand l'interface est servie sans webapp.
PRESETS_FILE = OUTPUT_DIR / ".presets.json"
_presets_lock = threading.Lock()
def _load_presets():
"""Lis la liste des presets depuis disque (vide si absent/corrompu)."""
try:
data = json.loads(PRESETS_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
except (OSError, ValueError):
pass
return []
def _save_presets(presets):
"""Écrit la liste des presets atomiquement (fichier temporaire + replace)."""
tmp = PRESETS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(presets, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, PRESETS_FILE)
class PresetRequest(BaseModel):
id: str = Field(..., description="identifiant du preset (attribué par l'interface)")
label: str = Field(..., description="nom affiché dans l'interface")
on: list = Field(default_factory=list, description="couches visibles (clés, ordre de pile)")
opacity: dict = Field(default_factory=dict, description="opacité de chaque couche, 0–1")
osm: dict = Field(default_factory=dict, description="fond de carte OSM : on, opacity, dark")
def _sanitize_preset(req: PresetRequest) -> dict:
"""Preset à stocker : label assaini, opacités bornées entre 0 et 1."""
on = [str(k) for k in (req.on or []) if str(k).strip()]
opacity = {}
for k, v in (req.opacity or {}).items():
try:
opacity[str(k)] = min(1.0, max(0.0, float(v)))
except (TypeError, ValueError):
continue
osm_in = req.osm if isinstance(req.osm, dict) else {}
osm = {"on": bool(osm_in.get("on", False))}
try:
osm["opacity"] = min(1.0, max(0.0, float(osm_in["opacity"])))
except (KeyError, TypeError, ValueError):
pass
if "dark" in osm_in:
osm["dark"] = bool(osm_in["dark"])
pid = re.sub(r"[^a-z0-9_-]", "", str(req.id).lower())
return {
"id": pid or "p" + str(int(time.time() * 1000)),
"label": (str(req.label).strip() or "Preset")[:80],
"on": on,
"opacity": opacity,
"osm": osm,
}
@app.get("/api/presets")
def list_presets():
"""Presets de couches personnalisés (partagés entre tous les navigateurs)."""
with _presets_lock:
return {"presets": _load_presets()}
@app.post("/api/presets", dependencies=[Depends(_require_token)])
def upsert_preset(req: PresetRequest):
"""Crée ou remplace un preset de couches (même identifiant = remplacement)."""
p = _sanitize_preset(req)
with _presets_lock:
presets = [x for x in _load_presets() if x.get("id") != p["id"]]
presets.append(p)
_save_presets(presets)
return p
@app.delete("/api/presets/{preset_id}", dependencies=[Depends(_require_token)])
def delete_preset(preset_id: str):
"""Supprime un preset de couches (404 s'il est inconnu)."""
with _presets_lock:
presets = _load_presets()
kept = [x for x in presets if x.get("id") != preset_id]
if len(kept) == len(presets):
raise HTTPException(404, "preset inconnu")
_save_presets(kept)
return {"ok": True}
def bbox_to_cells(w, s, e, n): def bbox_to_cells(w, s, e, n):
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84. """Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.