Interface : corrections à la souris et accès protégé

Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer
les médias sans position, masquer des zones de trace (pour un voyage, ou
zone privée pour tous). Corrections notées dans voyage.toml ([positions],
[corrections]) ou site.toml ([confidentialite]), appliquées par les pages ;
les originaux ne changent pas. L'extraction garde les médias datés sans
position pour qu'on puisse les placer.

Accès : clé d'accès aléatoire à chaque lancement, session par cookie
HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe,
empreinte scrypt hors du site, blocage après 5 échecs), accès réseau
seulement avec mot de passe (--distant), HTTPS possible (--certificat,
--cle), Content-Security-Policy et interdiction des cadres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 14:35:43 +00:00
1 parent 06153a227e
commit 71779f6f53
17 files changed
+1047 -38

No files matched your search

+199 -19
View File
@@ -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'<div class="erreur">{_echapper(message)}</div>')
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}://<nom ou adresse de cette machine>:{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: