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:
1 parent
06153a227e
commit
71779f6f53
17 files changed
+1047
-38
No files matched your search
+199
-19
@@ -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("&", "&").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}://<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:
|
||||
|
||||
Reference in new issue
Block a user