Centrer la carte sur le GPS du téléphone (HTTPS terminé par le proxy)

Le bouton ⌖ recentre la carte sur la position GPS du téléphone. L'API
Geolocation exige un contexte sécurisé : la webapp sert désormais du HTTP
et le TLS est porté par le reverse proxy (Traefik, labels dans l'override
local) ; le webapp est transparent au proxy (URLs relatives, X-Forwarded-For
pour la restriction LIDAR_REGEN_CIDR). Ajoute le modèle d'override
docker-compose.webapp.override.yml.example, un rebuild d'index à la demande
et met à jour doc/déploiement ; les tuiles sont servies à la demande.
This commit is contained in:
Antoine Jacquin
2026-09-13 19:57:09 +02:00
parent dd1aca3cfe
commit e2533bac48
16 changed files with 407 additions and 279 deletions

View File

@ -7,9 +7,8 @@ le téléchargement IGN). Endpoints :
GET /api/status → état de la génération en cours (ou dernière terminée)
GET /api/tiles → données de carte (index_tiles.json) pour l'affichage
en direct ; ?stamp=X → réponse allégée si inchangé
POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync
depuis la machine de traitement) puis régénérer la
carte en arrière-plan
POST /api/sync → régénérer l'index local (vignettes + carte) en
arrière-plan (les tuiles sont servies à la demande)
POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option
all_missing=true) toutes les dalles LHD présentes dans
input/ qui manquent au moins une visualisation demandée.
@ -48,10 +47,11 @@ Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère
(sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de
traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et
/api/status sont transmis tels quels au service distant, qui exécute la même
webapp avec le pipeline complet. Les résultats reviennent par la commande de
synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et
carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
(webapp légère) protègent optionnellement les appels distants.
webapp avec le pipeline complet. Les images produites sont servies à la
demande depuis GENERATION_URL (cache local peuplé au fil des consultations)
puis /api/sync régénère vignettes et carte localement. LIDAR_API_TOKEN
(machine de traitement) + LIDAR_REMOTE_TOKEN (webapp légère) protègent
optionnellement les appels distants.
"""
import json
@ -90,9 +90,6 @@ GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/")
# (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
@ -105,10 +102,6 @@ SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR",
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") or "").strip()
# Cache local périodique : relance la synchronisation + le rebuild toutes
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
# sa copie des tuiles de la machine de traitement.
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
# Résolution générée par /api/generate (cf. _build_command) : 0,2 m seule,
# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
@ -134,10 +127,6 @@ if GENERATION_URL:
else:
logger.info("Backend de génération des tuiles : local — consultation "
"autonome du cache, sans LIDAR_GENERATION_URL")
if SYNC_CMD:
logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD"
+ (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)"
if AUTO_SYNC_SECONDS > 0 else " (à la demande)"))
# 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.
@ -145,11 +134,11 @@ _assets_dir = OUTPUT_DIR / "assets"
_assets_dir.mkdir(parents=True, exist_ok=True)
# --- Cache à la demande : la visualisation peuple le cache local -------------
# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de
# traitement par HTTP : une image absente du disque — ou plus ancienne que la
# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar-
# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur-
# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore).
# Les images viennent de la machine de traitement par HTTP : une image absente
# du disque — ou plus ancienne que la version ?v= référencée (mtime ms de la
# source sur le worker) — est téléchargée depuis GENERATION_URL, écrite sur
# disque puis servie. Requêtes concurrentes dédupliquées (verrou par fichier),
# flux réseau plafonné (sémaphore).
from fastapi.staticfiles import StaticFiles as _StaticFiles
from starlette.concurrency import run_in_threadpool
@ -782,91 +771,46 @@ def tiles_data(stamp: Optional[float] = None):
}
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
# --- Rebuild de l'index en arrière-plan --------------------------------------
# Les tuiles sont servies à la demande (cache peuplé depuis la machine de
# traitement) : /api/sync ne fait plus qu'en régénérer l'index local
# (vignettes + carte).
_rebuild = {"running": False, "error": None, "done": None, "phase": None}
def _start_rebuild(with_sync):
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index.
Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées
par la machine distante (rsync) ; l'échec éventuel de la synchronisation
est remonté dans l'état sans empêcher le rebuild des données locales.
"""
def _start_rebuild():
"""Lance en arrière-plan le rebuild de l'index (vignettes + carte)."""
if _rebuild["running"]:
raise HTTPException(409, "une synchronisation/rebuild est déjà en cours")
raise HTTPException(409, "un rebuild est déjà en cours")
def _run():
_rebuild["running"] = True
_rebuild["error"] = None
_rebuild["done"] = None
sync_error = None
if with_sync and SYNC_CMD:
_rebuild["phase"] = "sync"
try:
r = subprocess.run(SYNC_CMD, shell=True, capture_output=True,
text=True, timeout=4 * 3600)
if r.returncode != 0:
tail = (r.stderr or r.stdout or "").strip()[-500:]
sync_error = (f"synchronisation échouée (rc={r.returncode})"
+ (f" : {tail}" if tail else ""))
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
sync_error = f"synchronisation échouée : {e}"
_rebuild["phase"] = "index"
try:
from .index import build_index
build_index(OUTPUT_DIR)
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e)
else:
_rebuild["error"] = sync_error
_rebuild["error"] = str(e)
finally:
_rebuild["running"] = False
_rebuild["phase"] = None
_rebuild["done"] = time.time()
threading.Thread(target=_run, daemon=True).start()
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
def _auto_sync_once():
"""Un cycle de cache local : synchronisation + rebuild, si rien ne tourne.
Retourne True si un cycle a été lancé, False si un rebuild était déjà en
cours (le prochain cycle rattrapera).
"""
if _rebuild["running"]:
return False
try:
_start_rebuild(with_sync=True)
return True
except HTTPException:
return False # course perdue contre un lancement manuel : tant pis
def _auto_sync_loop():
"""Boucle du cache local : synchronise les tuiles périodiquement."""
while True:
time.sleep(AUTO_SYNC_SECONDS)
_auto_sync_once()
# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de
# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a
# rien à synchroniser — pas de boucle (juste le rebuild à la demande).
if AUTO_SYNC_SECONDS > 0 and SYNC_CMD:
threading.Thread(target=_auto_sync_loop, daemon=True,
name="cache-tuiles").start()
return {"demarré": True}
@app.post("/api/sync", dependencies=[Depends(_require_token)])
def sync_and_rebuild():
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
"""Régénère l'index local (vignettes + carte) en arrière-plan.
Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple
rebuild de l'index — même comportement que /api/rebuild.
Les tuiles étant servies à la demande, il n'y a plus de synchronisation à
exécuter : /api/sync se réduit à un rebuild de l'index (même
comportement que /api/rebuild).
"""
return _start_rebuild(with_sync=True)
return _start_rebuild()
@app.get("/api/sync")
@ -877,7 +821,7 @@ def sync_status():
@app.post("/api/rebuild", dependencies=[Depends(_require_token)])
def rebuild_index():
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
return _start_rebuild(with_sync=False)
return _start_rebuild()
@app.get("/api/rebuild")
@ -1090,9 +1034,9 @@ def export_tiles(req: ExportRequest):
"""Assemble des dalles adjacentes en image ou PDF (export.py).
Local par nature : les tuiles assemblées sont celles du cache output/
(la webapp légère possède sa copie via rsync) — aucune délégation à la
machine de traitement. Un seul export à la fois (CPU/mémoire limités sur
Raspberry Pi).
(la webapp légère les possède via le cache à la demande) — aucune
délégation à la machine de traitement. Un seul export à la fois
(CPU/mémoire limités sur Raspberry Pi).
"""
import re as _re
from .export import EXPORT_FORMATS, build_export
@ -1153,4 +1097,13 @@ def export_file(name: str):
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)
# HTTPS optionnel : la géolocalisation du navigateur (bouton ⌖, centrage
# GPS depuis un téléphone) n'est disponible qu'en contexte sécurisé.
# Définir LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE (certificat auto-signé)
# pour servir la carte en https://<hôte>:PORT ; le téléphone accepte
# l'avertissement « certificat non fiable » puis le GPS fonctionne.
_ssl_cert = os.environ.get("LIDAR_SSL_CERTFILE")
_ssl_key = os.environ.get("LIDAR_SSL_KEYFILE")
_ssl_kw = ({"ssl_certfile": _ssl_cert, "ssl_keyfile": _ssl_key}
if _ssl_cert and _ssl_key else {})
uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl_kw)