Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque.
RTG ne les modifie jamais : les traces sont recopiées par journée dans routes/, les photos dans photos/ (HEIC converties en JPEG),
diff --git a/rtg/interface/securite.py b/rtg/interface/securite.py
new file mode 100644
index 0000000..d53fc12
--- /dev/null
+++ b/rtg/interface/securite.py
@@ -0,0 +1,93 @@
+"""Contrôle d'accès à l'interface.
+
+Deux façons d'entrer :
+- la clé d'accès (jeton) tirée au hasard à chaque lancement et ajoutée à
+ l'adresse que RTG ouvre dans le navigateur (et affiche dans le Terminal) :
+ seul celui qui voit ce Terminal peut entrer ;
+- le mot de passe défini par `rtg mot-de-passe`, indispensable pour un accès
+ depuis une autre machine (`rtg interface --distant`).
+
+Une fois entré, le navigateur garde un cookie de session (HttpOnly,
+SameSite=Strict), valable 12 heures après la dernière requête et perdu à
+l'arrêt de l'interface. Après 5 mots de passe faux, une adresse IP est
+bloquée une minute, puis deux, quatre… jusqu'à une heure.
+"""
+import hmac
+import secrets
+import threading
+import time
+
+from rtg import acces
+
+DUREE_SESSION = 12 * 3600
+ESSAIS_LIBRES = 5
+
+
+class Gardien:
+ def __init__(self, port, distant=False, https=False):
+ self.jeton = secrets.token_urlsafe(24)
+ self.cookie = f"rtg_{port}"
+ self.distant, self.https = distant, https
+ self._sessions = {}
+ self._echecs = {} # ip → (nombre d'échecs, heure du dernier)
+ self._verrou = threading.Lock()
+
+ # --- sessions
+ def ouvrir_session(self):
+ sid = secrets.token_urlsafe(32)
+ with self._verrou:
+ self._sessions[sid] = time.time() + DUREE_SESSION
+ return sid
+
+ def session_valide(self, sid):
+ if not sid:
+ return False
+ maintenant = time.time()
+ with self._verrou:
+ fin = self._sessions.get(sid)
+ if not fin or fin < maintenant:
+ self._sessions.pop(sid, None)
+ return False
+ self._sessions[sid] = maintenant + DUREE_SESSION
+ return True
+
+ def fermer_session(self, sid):
+ with self._verrou:
+ self._sessions.pop(sid, None)
+
+ def entete_cookie(self, sid, effacer=False):
+ valeur = f"{self.cookie}={'' if effacer else sid}; Path=/; HttpOnly; SameSite=Strict"
+ if effacer:
+ valeur += "; Max-Age=0"
+ if self.https:
+ valeur += "; Secure"
+ return valeur
+
+ # --- clé d'accès et mot de passe
+ def jeton_valide(self, jeton):
+ return bool(jeton) and hmac.compare_digest(jeton.encode(), self.jeton.encode())
+
+ def attente(self, ip):
+ """Secondes à attendre avant un nouvel essai (0 si libre)."""
+ with self._verrou:
+ n, dernier = self._echecs.get(ip, (0, 0))
+ if n < ESSAIS_LIBRES:
+ return 0
+ blocage = min(60 * 2 ** (n - ESSAIS_LIBRES), 3600)
+ return max(0, int(dernier + blocage - time.time()))
+
+ def essayer(self, ip, mdp):
+ """Vérifie le mot de passe. Renvoie (ok, message d'erreur)."""
+ reste = self.attente(ip)
+ if reste:
+ return False, f"Trop d'essais : réessayez dans {reste} s."
+ if not acces.defini():
+ return False, "Aucun mot de passe n'est défini (commande rtg mot-de-passe)."
+ if acces.verifier(mdp):
+ with self._verrou:
+ self._echecs.pop(ip, None)
+ return True, ""
+ with self._verrou:
+ n, _ = self._echecs.get(ip, (0, 0))
+ self._echecs[ip] = (n + 1, time.time())
+ return False, "Mot de passe incorrect."
diff --git a/rtg/interface/serveur.py b/rtg/interface/serveur.py
index f0e152e..ec0f18b 100644
--- a/rtg/interface/serveur.py
+++ b/rtg/interface/serveur.py
@@ -1,16 +1,23 @@
-"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
+"""Interface de RTG : un petit serveur web, ouvert dans le navigateur.
Rien n'est installé en plus : le serveur vient de Python, les pages sont des
modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
-fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
+fournis dans static/. Par défaut le serveur n'écoute que sur 127.0.0.1 ;
+`--distant` l'ouvre au réseau, avec mot de passe obligatoire.
-Sécurité : une page web quelconque ouverte dans le navigateur pourrait
-envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
-doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
-sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
+Sécurité (voir aussi securite.py) :
+- il faut la clé d'accès du lancement ou le mot de passe pour entrer ;
+- une page web quelconque ouverte dans le navigateur pourrait envoyer des
+ requêtes à ce serveur : toute requête qui modifie quelque chose doit donc
+ porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter sans
+ l'accord du serveur) ;
+- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ;
+- les pages de l'interface interdisent leur affichage dans un cadre et
+ n'exécutent que les scripts servis par RTG (Content-Security-Policy).
"""
import io
import json
+import ssl
import mimetypes
import os
import platform
@@ -26,10 +33,11 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
-from rtg import __version__, accueil, site, sources, video
+from rtg import __version__, acces, accueil, corrections, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.interface import taches
+from rtg.interface.securite import Gardien
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
ICI = os.path.dirname(os.path.abspath(__file__))
@@ -108,6 +116,9 @@ class Application:
("POST", r"/voyage/date", self.voyage_date),
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
+ ("GET", r"/voyage/carte.json", self.voyage_carte),
+ ("POST", r"/voyage/position", self.voyage_position),
+ ("POST", r"/voyage/masque", self.voyage_masque),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
@@ -260,10 +271,12 @@ class Application:
except OSError:
return (f, None, None)
fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
- autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
- autres.append(os.path.join(self.racine, SITE_TOML))
- photos = cfg["sources"].get("photos", [])
- return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
+ autres = [os.path.join(chemin, n) for n in ("export_photos.csv", "export_videos.csv")]
+ # Seuls les réglages qui changent l'analyse comptent : déplacer une
+ # photo sur la carte ne doit pas faire relire toutes les traces.
+ reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"],
+ cfg.get("exclusions", {})], sort_keys=True, default=str)
+ return tuple(etat(f) for f in fichiers + autres) + (reglages,)
def analyse(self, chemin):
cfg = self.cfg_voyage(chemin)
@@ -341,6 +354,92 @@ class Application:
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
+ # --- Carte et corrections
+ def _media_json(self, chemin, rel, m):
+ nom = m["fichier"]
+ if m["type"] == "photo":
+ dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos"
+ vignette = f"/site/{quote(rel)}/{dossier}/{quote(nom)}"
+ else:
+ base = os.path.splitext(nom)[0]
+ ap = os.path.join(chemin, "apercus", base + ".jpg")
+ vignette = f"/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None
+ d = m["date"][:10].replace(":", "-")
+ return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette)
+
+ def _masques_json(self, cfg):
+ return {"masques": cfg.get("corrections", {}).get("masques", []),
+ "zones": cfg.get("confidentialite", {}).get("zones", [])}
+
+ def voyage_carte(self, r):
+ rel = r.q("v")
+ chemin = self.chemin_voyage(rel)
+ cfg, a, jours, _ = self.analyse(chemin)
+ cfg = self.cfg_voyage(chemin) # positions et masques à jour
+ medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)]
+ return json_rep(dict(
+ self._masques_json(cfg),
+ couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)],
+ jours=self._jours_carte(jours, medias),
+ medias=medias))
+
+ @staticmethod
+ def _jours_carte(jours, medias):
+ """Jours des traces, plus ceux où seuls des médias existent."""
+ res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])}
+ for j in jours}
+ for m in medias:
+ if m["jour"] not in res:
+ try:
+ d = date.fromisoformat(m["jour"])
+ except ValueError:
+ continue
+ res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"}
+ return [res[k] for k in sorted(res)]
+
+ def voyage_position(self, r):
+ rel = r.f("v")
+ chemin = self.chemin_voyage(rel)
+ nom = r.f("fichier")
+ if not nom:
+ raise Erreur(400, "Média non précisé")
+ if r.f("lat") and r.f("lon"):
+ try:
+ lat, lon = float(r.f("lat")), float(r.f("lon"))
+ except ValueError:
+ raise Erreur(400, "Position invalide")
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
+ raise Erreur(400, "Position invalide")
+ self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}})
+ else:
+ self.toml_voyage(chemin, {"positions": {nom: None}})
+ cfg = self.cfg_voyage(chemin)
+ m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None)
+ return json_rep({"media": self._media_json(chemin, rel, m) if m else None})
+
+ def voyage_masque(self, r):
+ chemin = self.chemin_voyage(r.f("v"))
+ portee = "site" if r.f("portee") == "site" else "voyage"
+ cfg = self.cfg_voyage(chemin)
+ liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"])
+ if r.f("action") == "ajouter":
+ try:
+ lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon")))
+ except ValueError:
+ raise Erreur(400, "Zone invalide")
+ liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))])
+ elif r.f("action") == "supprimer":
+ try:
+ del liste[int(r.f("index"))]
+ except (ValueError, IndexError):
+ raise Erreur(400, "Zone introuvable")
+ if portee == "site":
+ modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}},
+ entete="Réglages du site RoadTripsGenerator (rtg).")
+ else:
+ self.toml_voyage(chemin, {"corrections": {"masques": liste}})
+ return json_rep(self._masques_json(self.cfg_voyage(chemin)))
+
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
@@ -511,11 +610,21 @@ class Requete:
return _valeur(self.form, k, defaut)
-def creer_gestionnaire(app, port):
+# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds
+# de carte, aucun affichage dans un cadre.
+CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; "
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
+PUBLIQUES = {"/connexion"}
+
+
+def creer_gestionnaire(app, port, gardien=None):
+ gardien = gardien or Gardien(port)
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
class Gestionnaire(BaseHTTPRequestHandler):
server_version = f"rtg/{__version__}"
+ sys_version = ""
def log_message(self, fmt, *args):
pass # le terminal sert au journal des traitements
@@ -529,23 +638,59 @@ def creer_gestionnaire(app, port):
def do_POST(self):
self.traiter("POST")
+ def session(self):
+ for morceau in (self.headers.get("Cookie") or "").split(";"):
+ nom, _, valeur = morceau.strip().partition("=")
+ if nom == gardien.cookie:
+ return valeur
+ return ""
+
+ def rediriger(self, cible, cookie=None):
+ self.send_response(303)
+ self.send_header("Location", cible)
+ if cookie:
+ self.send_header("Set-Cookie", cookie)
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
def traiter(self, methode, tete=False):
try:
- if self.headers.get("Host") not in hotes:
+ if not gardien.distant and self.headers.get("Host") not in hotes:
raise Erreur(403, "Hôte refusé")
url = urlsplit(self.path)
chemin = unquote(url.path)
+ query = parse_qs(url.query, keep_blank_values=True)
if methode == "GET" and chemin.startswith("/static/"):
return self.fichier(STATIC, chemin[len("/static/"):], tete)
- if methode == "GET" and chemin.startswith("/site/"):
- return self.fichier(app.racine, chemin[len("/site/"):], tete)
+ # Adresse ouverte au lancement : la clé ouvre une session, puis
+ # on la retire de l'adresse (historique, copier-coller).
+ if "jeton" in query:
+ if gardien.jeton_valide(_valeur(query, "jeton", "")):
+ sid = gardien.ouvrir_session()
+ return self.rediriger(url.path or "/", gardien.entete_cookie(sid))
+ return self.rediriger("/connexion")
+ connecte = gardien.session_valide(self.session())
form = {}
if methode == "POST":
if self.headers.get("HX-Request") != "true":
raise Erreur(403, "Requête refusée")
n = int(self.headers.get("Content-Length") or 0)
+ if n > 2_000_000:
+ raise Erreur(413, "Requête trop grosse")
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
- req = Requete(parse_qs(url.query, keep_blank_values=True), form)
+ if chemin in PUBLIQUES:
+ return self.envoyer(self.connexion(methode, form, connecte), tete)
+ if not connecte:
+ if self.headers.get("HX-Request") == "true":
+ return self.envoyer(Reponse("", entetes={"HX-Redirect": "/connexion"}), tete)
+ return self.rediriger("/connexion")
+ if chemin == "/deconnexion" and methode == "POST":
+ gardien.fermer_session(self.session())
+ return self.envoyer(Reponse("", entetes={
+ "HX-Redirect": "/connexion", "Set-Cookie": gardien.entete_cookie("", effacer=True)}), tete)
+ if methode == "GET" and chemin.startswith("/site/"):
+ return self.fichier(app.racine, chemin[len("/site/"):], tete)
+ req = Requete(query, form)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
@@ -568,11 +713,26 @@ def creer_gestionnaire(app, port):
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
self.envoyer(rep, tete)
+ def connexion(self, methode, form, connecte):
+ ip = self.client_address[0]
+ if methode == "POST":
+ ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", ""))
+ if ok:
+ sid = gardien.ouvrir_session()
+ return Reponse("", entetes={"HX-Redirect": "/", "Set-Cookie": gardien.entete_cookie(sid)})
+ return Reponse(f'
{_echapper(message)}
')
+ return page("connexion.html", mdp=acces.defini(), connecte=connecte,
+ distant=gardien.distant, attente=gardien.attente(ip))
+
def envoyer(self, rep, tete=False):
self.send_response(rep.code)
self.send_header("Content-Type", rep.type)
self.send_header("Content-Length", str(len(rep.corps)))
self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("X-Frame-Options", "DENY")
+ self.send_header("Content-Security-Policy", CSP)
+ self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
for k, v in rep.entetes.items():
self.send_header(k, v)
self.end_headers()
@@ -642,21 +802,41 @@ def _echapper(s):
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
-def lancer(racine, port=8765, ouvrir=True):
+def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None):
+ """Lance l'interface. distant=True : écoute sur toutes les interfaces
+ réseau, mot de passe obligatoire ; certificat et cle : HTTPS."""
+ if distant and not acces.defini():
+ raise SystemExit("❌ L'accès distant demande un mot de passe : lancez d'abord « rtg mot-de-passe ».")
+ contexte_tls = None
+ if certificat:
+ contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2
+ contexte_tls.load_cert_chain(certificat, cle)
app = Application(racine)
serveur = None
for p in range(port, port + 20):
try:
- serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
+ gardien = Gardien(p, distant=distant, https=bool(contexte_tls))
+ serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p),
+ creer_gestionnaire(app, p, gardien))
port = p
break
except OSError:
continue
if serveur is None:
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
+ if contexte_tls:
+ serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True)
serveur.daemon_threads = True
- url = f"http://127.0.0.1:{port}/"
+ schema = "https" if contexte_tls else "http"
+ url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}"
print(f"🌐 Interface RTG : {url}")
+ print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)")
+ if distant:
+ print(f" Accès depuis le réseau : {schema}://:{port}/ (mot de passe)")
+ if not contexte_tls:
+ print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre")
+ print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.")
print(f" Site : {app.racine}")
print(" (Ctrl+C pour arrêter)")
if ouvrir:
diff --git a/rtg/interface/static/editeur.js b/rtg/interface/static/editeur.js
new file mode 100644
index 0000000..94c7b2b
--- /dev/null
+++ b/rtg/interface/static/editeur.js
@@ -0,0 +1,282 @@
+// Onglet « Carte et corrections » : déplacer ou placer les photos et vidéos,
+// masquer des zones de trace. Chaque correction est enregistrée aussitôt dans
+// voyage.toml (ou site.toml pour une zone privée) ; les pages du site la
+// prennent en compte à la génération suivante.
+(function () {
+ "use strict";
+ const racine = document.getElementById("editeur");
+ if (!racine || !window.L) return;
+ const voyage = racine.dataset.voyage;
+ const $ = id => document.getElementById(id);
+ let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones;
+ let brouillon = null, modeZone = false, aPlacer = null, cadree = false;
+
+ // --- Échanges avec le serveur -------------------------------------------
+ function message(html) {
+ const m = $("messages");
+ m.innerHTML = html;
+ clearTimeout(window._rtgMessage);
+ window._rtgMessage = setTimeout(() => { m.innerHTML = ""; }, 6000);
+ }
+
+ function lireReponse(r) {
+ const redirection = r.headers.get("HX-Redirect");
+ if (redirection) { location.href = redirection; throw new Error("session"); }
+ if (!(r.headers.get("Content-Type") || "").includes("json")) {
+ return r.text().then(t => { message(t); throw new Error("refus"); });
+ }
+ return r.json();
+ }
+
+ function poster(url, params) {
+ return fetch(url, {
+ method: "POST",
+ headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams(Object.assign({ v: voyage }, params))
+ }).then(lireReponse);
+ }
+
+ function etat(texte) {
+ $("ed-etat").textContent = texte;
+ }
+
+ function charger() {
+ return fetch("/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
+ .then(lireReponse)
+ .then(d => { donnees = d; remplirJours(); dessinerTout(); });
+ }
+
+ // --- Carte ----------------------------------------------------------------
+ function initialiser() {
+ $("ed-carte").innerHTML = "";
+ carte = L.map("ed-carte");
+ const plan = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(carte);
+ const satellite = L.tileLayer(
+ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
+ { maxZoom: 19, attribution: "Esri World Imagery" });
+ L.control.layers({ "Plan": plan, "Satellite": satellite }).addTo(carte);
+ carte.setView([46.5, 2.5], 5);
+ coucheTraces = L.featureGroup().addTo(carte);
+ coucheZones = L.featureGroup().addTo(carte);
+ coucheMedias = L.featureGroup().addTo(carte);
+ carte.on("click", clicCarte);
+ $("ed-jour").addEventListener("change", () => { cadree = false; dessinerTout(); });
+ $("ed-photos").addEventListener("change", dessinerMedias);
+ $("ed-videos").addEventListener("change", dessinerMedias);
+ $("ed-nouvelle-zone").addEventListener("click", commencerZone);
+ $("ed-zone-annuler").addEventListener("click", finirZone);
+ $("ed-zone-ok").addEventListener("click", enregistrerZone);
+ $("ed-rayon").addEventListener("input", () => {
+ $("ed-rayon-texte").textContent = $("ed-rayon").value + " m";
+ if (brouillon) brouillon.setRadius(+$("ed-rayon").value);
+ });
+ document.addEventListener("keydown", e => {
+ if (e.key === "Escape") { aPlacer = null; finirZone(); mode(""); }
+ });
+ document.body.addEventListener("tache-finie", () => charger());
+ charger();
+ }
+
+ function remplirJours() {
+ const choix = $("ed-jour"), actuel = choix.value;
+ choix.length = 1;
+ donnees.jours.forEach(j => choix.add(new Option(j.nom, j.jour)));
+ choix.value = actuel;
+ }
+
+ function jourChoisi() { return $("ed-jour").value; }
+
+ function dessinerTout() {
+ dessinerTraces();
+ dessinerZones();
+ dessinerMedias();
+ if (!cadree) {
+ const b = coucheTraces.getLayers().length ? coucheTraces.getBounds()
+ : (coucheMedias.getLayers().length ? coucheMedias.getBounds() : null);
+ if (b && b.isValid()) carte.fitBounds(b, { padding: [30, 30] });
+ cadree = true;
+ }
+ }
+
+ function dessinerTraces() {
+ coucheTraces.clearLayers();
+ const jour = jourChoisi();
+ donnees.couches.forEach(c => {
+ if (jour && c.jour !== jour) return;
+ c.segments.forEach(s => L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, interactive: false })
+ .addTo(coucheTraces));
+ });
+ }
+
+ function icone(m) {
+ return L.divIcon({ className: "", iconSize: [16, 16], iconAnchor: [8, 8],
+ html: '' });
+ }
+
+ function popup(m) {
+ const d = document.createElement("div");
+ d.className = "ed-popup";
+ if (m.vignette) {
+ const img = document.createElement("img");
+ img.src = m.vignette; img.alt = ""; img.loading = "lazy";
+ d.append(img);
+ }
+ const t = document.createElement("div");
+ t.textContent = m.fichier + " · " + m.jour + " " + m.heure;
+ d.append(t);
+ if (m.deplace) {
+ const b = document.createElement("button");
+ b.className = "petit";
+ b.textContent = "Revenir à la position d'origine";
+ b.addEventListener("click", () => { carte.closePopup(); enregistrerPosition(m.fichier, "", ""); });
+ d.append(b);
+ }
+ return d;
+ }
+
+ function dessinerMedias() {
+ coucheMedias.clearLayers();
+ const jour = jourChoisi(), photos = $("ed-photos").checked, videos = $("ed-videos").checked;
+ const sans = [];
+ donnees.medias.forEach(m => {
+ if (jour && m.jour !== jour) return;
+ if ((m.type === "photo" && !photos) || (m.type === "video" && !videos)) return;
+ if (m.lat === null) { sans.push(m); return; }
+ L.marker([m.lat, m.lon], { icon: icone(m), draggable: true, title: m.fichier })
+ .bindPopup(() => popup(m), { minWidth: 200 })
+ .on("dragend", e => {
+ const p = e.target.getLatLng();
+ enregistrerPosition(m.fichier, p.lat.toFixed(6), p.lng.toFixed(6));
+ })
+ .addTo(coucheMedias);
+ });
+ const liste = $("ed-sans");
+ liste.innerHTML = "";
+ $("ed-nb-sans").textContent = sans.length;
+ sans.forEach(m => {
+ const li = document.createElement("li");
+ const a = document.createElement("a");
+ a.href = "#carte";
+ a.textContent = (m.type === "photo" ? "📷 " : "🎬 ") + m.fichier;
+ a.title = m.jour + " " + m.heure;
+ a.addEventListener("click", e => {
+ e.preventDefault();
+ finirZone();
+ aPlacer = m.fichier;
+ mode("Cliquez sur la carte pour placer " + m.fichier + " (Échap pour annuler)");
+ });
+ const quand = document.createElement("span");
+ quand.className = "discret";
+ quand.textContent = " " + m.jour + " " + m.heure;
+ li.append(a, quand);
+ liste.append(li);
+ });
+ if (!sans.length) liste.innerHTML = 'Aucun';
+ }
+
+ function enregistrerPosition(fichier, lat, lon) {
+ poster("/voyage/position", { fichier: fichier, lat: lat, lon: lon }).then(r => {
+ if (r.media) {
+ const i = donnees.medias.findIndex(m => m.fichier === fichier);
+ if (i >= 0) donnees.medias[i] = r.media;
+ }
+ dessinerMedias();
+ etat("✓ " + fichier + (lat ? " placé" : " remis à sa position d'origine")
+ + ". Régénérez le voyage pour mettre le site à jour.");
+ }).catch(() => dessinerMedias());
+ }
+
+ // --- Zones masquées -------------------------------------------------------
+ function dessinerZones() {
+ coucheZones.clearLayers();
+ const liste = $("ed-zones");
+ liste.innerHTML = "";
+ const ajouter = (z, i, portee) => {
+ const site = portee === "site";
+ const cercle = L.circle([z[0], z[1]], {
+ radius: z[2], color: site ? "#7c3aed" : "#dc2626", weight: 2, fillOpacity: 0.15,
+ dashArray: site ? "6 6" : null
+ }).addTo(coucheZones);
+ const li = document.createElement("li");
+ const a = document.createElement("a");
+ a.href = "#carte";
+ a.textContent = (site ? "Zone privée" : "Zone") + " · " + z[2] + " m";
+ a.addEventListener("click", e => { e.preventDefault(); carte.fitBounds(cercle.getBounds(), { maxZoom: 16 }); });
+ const b = document.createElement("button");
+ b.className = "petit";
+ b.textContent = "Supprimer";
+ b.addEventListener("click", () => {
+ poster("/voyage/masque", { action: "supprimer", portee: portee, index: i }).then(r => {
+ donnees.masques = r.masques; donnees.zones = r.zones; dessinerZones();
+ etat("✓ Zone supprimée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
+ });
+ });
+ li.append(a, " ", b);
+ if (site) li.title = "Retirée de tous les voyages (site.toml)";
+ liste.append(li);
+ };
+ donnees.masques.forEach((z, i) => ajouter(z, i, "voyage"));
+ donnees.zones.forEach((z, i) => ajouter(z, i, "site"));
+ }
+
+ function commencerZone() {
+ aPlacer = null;
+ modeZone = true;
+ mode("Cliquez au centre de la zone à masquer ; un nouveau clic la déplace.");
+ }
+
+ function finirZone() {
+ modeZone = false;
+ if (brouillon) { brouillon.remove(); brouillon = null; }
+ $("ed-brouillon").hidden = true;
+ if (!aPlacer) mode("");
+ }
+
+ function enregistrerZone() {
+ if (!brouillon) return;
+ const c = brouillon.getLatLng(), site = $("ed-portee-site").checked;
+ poster("/voyage/masque", {
+ action: "ajouter", portee: site ? "site" : "voyage",
+ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), rayon: $("ed-rayon").value
+ }).then(r => {
+ donnees.masques = r.masques; donnees.zones = r.zones;
+ finirZone();
+ dessinerZones();
+ etat("✓ Zone enregistrée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
+ });
+ }
+
+ function clicCarte(e) {
+ if (aPlacer) {
+ const nom = aPlacer;
+ aPlacer = null;
+ mode("");
+ enregistrerPosition(nom, e.latlng.lat.toFixed(6), e.latlng.lng.toFixed(6));
+ } else if (modeZone) {
+ if (!brouillon) {
+ brouillon = L.circle(e.latlng, { radius: +$("ed-rayon").value, color: "#dc2626", weight: 2,
+ dashArray: "4 4", fillOpacity: 0.2 }).addTo(carte);
+ } else {
+ brouillon.setLatLng(e.latlng);
+ }
+ $("ed-brouillon").hidden = false;
+ mode("Ajustez le rayon puis enregistrez ; un clic sur la carte déplace la zone.");
+ }
+ }
+
+ function mode(texte) {
+ const m = $("ed-mode");
+ m.textContent = texte;
+ m.hidden = !texte;
+ $("ed-carte").classList.toggle("en-mode", !!texte);
+ }
+
+ // L'onglet peut être caché au chargement : on prépare la carte à sa première ouverture.
+ document.addEventListener("rtg:onglet", e => {
+ if (e.detail !== "carte") return;
+ if (!carte) initialiser();
+ else carte.invalidateSize();
+ });
+ if (location.hash === "#carte") setTimeout(() => { if (!carte) initialiser(); }, 0);
+})();
diff --git a/rtg/interface/static/rtg.css b/rtg/interface/static/rtg.css
index 26946a6..9c2d268 100644
--- a/rtg/interface/static/rtg.css
+++ b/rtg/interface/static/rtg.css
@@ -145,3 +145,43 @@ progress.global { height: 4px; opacity: .7; }
th, td { padding: 6px 8px; }
.carte { height: 300px; }
}
+
+/* Connexion */
+.page-connexion main.connexion { max-width: 420px; margin: 12vh auto; background: var(--carte); border: 1px solid var(--bord);
+ border-radius: 14px; padding: 24px 28px; }
+.connexion .logo { margin: 0 0 8px; }
+.connexion label { display: block; margin: 14px 0 10px; font-weight: 500; }
+.connexion input[type=password] { display: block; width: 100%; margin-top: 4px; }
+.discret-bouton { color: var(--discret); }
+
+/* Carte et corrections */
+.editeur { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; }
+.editeur-panneau { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 14px 12px;
+ max-height: 74vh; overflow-y: auto; }
+.editeur-panneau h4 { margin: 18px 0 4px; font-size: 14px; }
+.editeur-panneau label { display: block; margin: 8px 0; }
+.editeur-panneau select { width: 100%; margin-top: 4px; }
+.editeur-panneau input[type=range] { width: 100%; }
+.ed-cases { display: flex; gap: 16px; }
+.editeur-carte { height: 74vh; border-radius: 10px; border: 1px solid var(--bord); }
+.editeur-carte.en-mode { cursor: crosshair; outline: 3px solid var(--accent); }
+.editeur-carte.en-mode .leaflet-container, .editeur-carte.en-mode.leaflet-container { cursor: crosshair; }
+.ed-mode { background: var(--attention-fond); color: var(--attention); padding: 6px 10px; border-radius: 8px; font-size: 13px; margin: 8px 0; }
+.ed-brouillon { background: var(--neutre-fond); border-radius: 8px; padding: 4px 10px; margin: 8px 0; }
+.ed-liste { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; }
+.ed-liste li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.ed-liste a { text-decoration: none; overflow-wrap: anywhere; }
+.mk { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.35); vertical-align: middle; }
+.mk.photo { background: #ef4444; }
+.mk.video { background: #2563eb; }
+.mk.deplace { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; }
+.leaflet-marker-draggable .mk { cursor: grab; }
+.ed-popup { max-width: 240px; font-size: 12px; }
+.ed-popup img { display: block; max-width: 240px; max-height: 180px; border-radius: 6px; margin-bottom: 4px; }
+.ed-popup button { margin-top: 6px; }
+@media (max-width: 800px) {
+ .editeur { grid-template-columns: 1fr; }
+ .editeur-panneau { max-height: none; }
+ .editeur-carte { height: 60vh; }
+}
diff --git a/rtg/interface/static/rtg.js b/rtg/interface/static/rtg.js
index 7b25c18..489337d 100644
--- a/rtg/interface/static/rtg.js
+++ b/rtg/interface/static/rtg.js
@@ -18,6 +18,7 @@
s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1)));
});
document.querySelectorAll(".onglet.actif .carte").forEach(cadrer);
+ document.dispatchEvent(new CustomEvent("rtg:onglet", { detail: trouve ? nom : "" }));
}
window.addEventListener("hashchange", afficherOnglet);
document.addEventListener("DOMContentLoaded", afficherOnglet);
diff --git a/rtg/pages.py b/rtg/pages.py
index 8289284..932fe8f 100644
--- a/rtg/pages.py
+++ b/rtg/pages.py
@@ -35,6 +35,8 @@ from folium import plugins
from folium.plugins import MarkerCluster
from jinja2 import Environment, FileSystemLoader
+from rtg import corrections
+
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
# Fixés par generer() : racine du site et adresse publique.
CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"}
@@ -101,13 +103,18 @@ def bornes(points, marge=0.02, robuste=True):
len(points) - len(gardes))
-def lire_csv(chemin):
- """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour."""
+def lire_csv(chemin, positions=None):
+ """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.
+ `positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent."""
vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour'])
# Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire.
if not os.path.exists(chemin) or os.path.getsize(chemin) == 0:
return vide
df = pd.read_csv(chemin, names=COLONNES, header=None)
+ for nom, (lat, lon) in (positions or {}).items():
+ lignes = df['Fichier'].astype(str).str.strip() == nom
+ df.loc[lignes, 'Latitude'] = lat
+ df.loc[lignes, 'Longitude'] = lon
df = df[df['Latitude'].notnull() & df['Longitude'].notnull()]
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy()
if df.empty:
@@ -312,12 +319,16 @@ def exporter_trace_globale(base_dir, pistes):
class Voyage:
"""Données communes à toutes les pages d'un voyage."""
- def __init__(self, base_dir):
+ def __init__(self, base_dir, cfg=None):
self.base_dir = base_dir
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
self.routes_dir = os.path.join(base_dir, "routes")
- self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"))
- self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"))
+ cfg = cfg or {}
+ self.zones = corrections.masques(cfg)
+ pos = corrections.positions(cfg)
+ self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos)
+ self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos)
+ self.points_masques = 0
jours_gpx = set()
for f in fichiers_gpx(self.routes_dir):
m = re.search(r'(\d{4}-\d{2}-\d{2})', f)
@@ -343,6 +354,7 @@ class Voyage:
continue
self.gpx_du_jour[jour] = gpx_file
for track in gpx.tracks:
+ self.points_masques += corrections.filtrer_piste(track, self.zones)
pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points]
if pts:
if not track.name:
@@ -396,19 +408,22 @@ def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telecharg
return m_day, media_coords
-def generer(base_dir, racine=None, site_url=None):
- """Pages d'un voyage. `racine` : racine du site (bouton « Retour »)."""
+def generer(base_dir, racine=None, site_url=None, cfg=None):
+ """Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ;
+ `cfg` : réglages du voyage (corrections faites dans l'interface)."""
CONTEXTE["racine"] = racine
if site_url:
CONTEXTE["site_url"] = site_url.rstrip('/') + '/'
base_dir = os.path.abspath(base_dir)
html_dir = os.path.join(base_dir, "html")
os.makedirs(html_dir, exist_ok=True)
- v = Voyage(base_dir)
+ v = Voyage(base_dir, cfg)
if not v.jours:
print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).")
return False
v.lire_traces()
+ if v.points_masques:
+ print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées")
version = installer_assets(base_dir)
journal = Journal(base_dir, v.jours)
diff --git a/rtg/site.py b/rtg/site.py
index cf8983a..34c3190 100644
--- a/rtg/site.py
+++ b/rtg/site.py
@@ -87,7 +87,7 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suiv
etape("Photos")
photos.preparer(voyage, cfg, force=force)
etape("Pages")
- ok = pages.generer(voyage, racine, cfg["adresse_publique"])
+ ok = pages.generer(voyage, racine, cfg["adresse_publique"], cfg)
dire()
return ok
diff --git a/tests/test_interface.py b/tests/test_interface.py
index 75aacf9..1c6fd64 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -10,7 +10,9 @@ from http.server import ThreadingHTTPServer
import pytest
from rtg import config, sources
+from rtg import acces, corrections
from rtg.interface import serveur
+from rtg.interface.securite import Gardien
from test_chaine import _photo, pytestmark # noqa: F401 (mêmes prérequis)
@@ -64,19 +66,24 @@ def client(voyage_sans_heures):
app = serveur.Application(str(racine))
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
port = srv.server_address[1]
- srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port)
+ gardien = Gardien(port)
+ srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
threading.Thread(target=srv.serve_forever, daemon=True).start()
+ sid = gardien.ouvrir_session()
- def appel(chemin, donnees=None, htmx=True):
+ def appel(chemin, donnees=None, htmx=True, session=True):
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}",
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
if htmx:
req.add_header("HX-Request", "true")
+ if session:
+ req.add_header("Cookie", f"{gardien.cookie}={sid}")
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
+ appel.port, appel.gardien = port, gardien
yield appel, racine, voyage
srv.shutdown()
@@ -98,3 +105,74 @@ def test_serveur(client):
# Voyage hors du site : refusé
code, html = appel("/voyage/analyse?v=../../etc")
assert "introuvable" in html.lower()
+
+
+class _SansRedirection(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, *a, **k):
+ return None
+
+
+def test_acces(client, tmp_path, monkeypatch):
+ appel, racine, voyage = client
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
+ base = f"http://127.0.0.1:{appel.port}"
+ ouvreur = urllib.request.build_opener(_SansRedirection)
+
+ def brut(chemin):
+ try:
+ return ouvreur.open(base + chemin)
+ except urllib.error.HTTPError as e:
+ return e
+ # Sans session : renvoyé vers la connexion, y compris pour les fichiers du site
+ for chemin in ("/", "/site/site.toml", "/voyage?v=Ecosse-2027"):
+ r = brut(chemin)
+ assert r.status == 303 and r.headers["Location"] == "/connexion", chemin
+ assert appel("/", session=False)[0] == 200 # htmx : HX-Redirect, corps vide
+ # La clé d'accès ouvre une session (cookie HttpOnly), une fausse clé non
+ r = brut("/?jeton=" + appel.gardien.jeton)
+ assert r.status == 303 and "HttpOnly" in r.headers["Set-Cookie"] and "SameSite=Strict" in r.headers["Set-Cookie"]
+ assert brut("/?jeton=faux").headers["Location"] == "/connexion"
+ # Mot de passe : faux, juste, puis blocage après 5 échecs
+ acces.definir("un mot de passe solide")
+ code, html = appel("/connexion", {"mot_de_passe": "non"}, session=False)
+ assert "incorrect" in html
+ ok, _ = appel.gardien.essayer("10.0.0.1", "un mot de passe solide")
+ assert ok
+ for _ in range(5):
+ appel.gardien.essayer("10.0.0.2", "faux")
+ ok, message = appel.gardien.essayer("10.0.0.2", "un mot de passe solide")
+ assert not ok and "Trop d'essais" in message
+ # En-têtes de sécurité
+ r = urllib.request.urlopen(urllib.request.Request(base + "/connexion"))
+ assert r.headers["X-Frame-Options"] == "DENY" and "frame-ancestors 'none'" in r.headers["Content-Security-Policy"]
+
+
+def test_corrections(client):
+ appel, racine, voyage = client
+ # Médias : une photo localisée, une sans position
+ with open(voyage / "export_photos.csv", "w") as f:
+ f.write('"P1.jpg",2027:06:11 10:00:00,56.75,2.30,\n"P2.jpg",2027:06:11 11:00:00,,,\n')
+ code, texte = appel("/voyage/position", {"v": "Ecosse-2027", "fichier": "P2.jpg", "lat": "56.8", "lon": "2.36"})
+ assert '"deplace": true' in texte
+ cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
+ assert corrections.positions(cfg) == {"P2.jpg": (56.8, 2.36)}
+ # Zone masquée du voyage, puis zone privée du site
+ appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "lat": "56.7", "lon": "2.24", "rayon": "3000"})
+ appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "portee": "site",
+ "lat": "48.8", "lon": "2.1", "rayon": "500"})
+ cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
+ assert corrections.masques(cfg) == [(56.7, 2.24, 3000.0), (48.8, 2.1, 500.0)]
+ # Génération : la photo placée apparaît, le début d'Etape 2 est retiré
+ config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10",
+ "Etape 2.gpx": "2027-06-11"}})
+ from rtg import pages, sources
+ cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
+ sources.ranger(str(voyage), cfg, sources.analyser(str(voyage), cfg))
+ v = pages.Voyage(str(voyage), cfg)
+ assert set(v.df_p["Fichier"]) == {"P1.jpg", "P2.jpg"}
+ v.lire_traces()
+ assert v.points_masques > 0
+ for pistes in v.traces.values():
+ for piste, pts in pistes:
+ assert all(corrections.distance_m(la, lo, 56.7, 2.24) > 3000 for la, lo in pts)