"""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/. 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é (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 import re import shutil import string import threading import webbrowser from contextlib import redirect_stdout from datetime import date, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, quote, unquote, urlsplit from jinja2 import Environment, FileSystemLoader, select_autoescape 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__)) STATIC = os.path.join(ICI, "static") MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] COULEURS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46a0b4", "#f032e6", "#7a9a01", "#c47f00", "#008080", "#9a6324", "#800000", "#2f4b7c", "#808000"] CLES_SOURCES = {"traces": ("Traces GPS", (".gpx",), "traces GPS"), "videos": ("Vidéos", EXT_VIDEOS, "vidéos"), "photos": ("Photos", EXT_PHOTOS, "photos")} def _date_longue(d): return f"{JOURS[d.weekday()]} {d.day} {MOIS[d.month - 1]} {d.year}" if d else "" def _periode(debut, fin): if not debut: return "" if debut == fin: return f"{debut.day} {MOIS[debut.month - 1]} {debut.year}" if debut.year == fin.year: return f"{debut.day} {MOIS[debut.month - 1]} – {fin.day} {MOIS[fin.month - 1]} {fin.year}" return f"{debut.day} {MOIS[debut.month - 1]} {debut.year} – {fin.day} {MOIS[fin.month - 1]} {fin.year}" ENV = Environment(loader=FileSystemLoader(os.path.join(ICI, "modeles")), autoescape=select_autoescape(["html"]), trim_blocks=True, lstrip_blocks=True) ENV.filters["date_longue"] = _date_longue ENV.filters["date_courte"] = lambda d: f"{d.day} {MOIS[d.month - 1][:4].rstrip('.')}" if d else "" ENV.filters["taille"] = taille_lisible ENV.filters["url"] = lambda s: quote(str(s), safe="") ENV.tests["commence_par"] = lambda s, debut: str(s).startswith(debut) ENV.globals.update(version=__version__, periode=_periode, qualites=QUALITES, noms_qualites={"econome": "économe", "equilibree": "équilibrée", "haute": "haute"}) class Erreur(Exception): def __init__(self, code, message): super().__init__(message) self.code, self.message = code, message class Reponse: def __init__(self, corps=b"", type_="text/html; charset=utf-8", code=200, entetes=None): self.corps = corps.encode("utf-8") if isinstance(corps, str) else corps self.type, self.code, self.entetes = type_, code, entetes or {} def page(modele, **ctx): return Reponse(ENV.get_template(modele).render(**ctx)) def json_rep(donnees): return Reponse(json.dumps(donnees, ensure_ascii=False, default=str), "application/json") # --- Application --------------------------------------------------------------- class Application: def __init__(self, racine, prefixe=""): self.racine = os.path.abspath(racine) # Préfixe d'adresse quand l'interface est servie sous le site # (https://…/admin/ derrière le serveur web) : « /admin ». self.prefixe = ("/" + prefixe.strip("/")) if prefixe.strip("/") else "" ENV.globals["base"] = self.prefixe self._cache = {} # voyage → (empreinte des sources, analyse) self.routes = [] for methode, motif, fn in [ ("GET", r"/", self.accueil), ("POST", r"/site/creer", self.creer_site), ("GET", r"/nouveau", self.nouveau), ("POST", r"/nouveau", self.nouveau_creer), ("GET", r"/voyage", self.voyage), ("POST", r"/voyage/infos", self.voyage_infos), ("POST", r"/voyage/recit", self.voyage_recit), ("POST", r"/voyage/sources", self.voyage_sources), ("GET", r"/voyage/analyse", self.voyage_analyse), ("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), ("GET", r"/systeme", self.systeme), ("POST", r"/tache/lancer", self.tache_lancer), ("POST", r"/tache/arreter", self.tache_arreter), ("GET", r"/tache", self.tache), ("GET", r"/tache/fragment", self.tache_fragment), ("GET", r"/tache/bandeau", self.tache_bandeau), ]: self.routes.append((methode, re.compile(motif + "$"), fn)) # --- outils def contexte(self, **ctx): ctx.setdefault("racine", self.racine) ctx.setdefault("site_existe", os.path.exists(os.path.join(self.racine, SITE_TOML))) ctx.setdefault("tache", taches.COURANTE) return ctx def chemin_voyage(self, rel): if not rel: raise Erreur(400, "Voyage non précisé") chemin = os.path.realpath(os.path.join(self.racine, rel)) if not chemin.startswith(os.path.realpath(self.racine) + os.sep) or not site.est_voyage(chemin): raise Erreur(404, f"Voyage introuvable : {rel}") return chemin def cfg_voyage(self, chemin): return reglages_voyage(chemin, reglages_site(self.racine)) def fiche_voyage(self, chemin): return site.etat_voyage(chemin, self.racine, reglages_site(self.racine)) def toml_voyage(self, chemin, changements): modifier_toml(os.path.join(chemin, VOYAGE_TOML), changements, entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.") # --- Mes voyages def accueil(self, r): voyages = site.tableau_etat(self.racine) groupes = {} for v in sorted(voyages, key=lambda v: v["debut"] or date.min, reverse=True): groupes.setdefault(v["rubrique"], []).append(v) ordre = sorted(groupes, key=lambda g: (g != "Voyages", g)) return page("voyages.html", **self.contexte(groupes=[(g, groupes[g]) for g in ordre], nb=len(voyages))) def creer_site(self, r): chemin = os.path.join(self.racine, SITE_TOML) if not os.path.exists(chemin): modifier_toml(chemin, {"titre": "Nos Road-Trips"}, entete="Réglages du site RoadTripsGenerator (rtg).") return Reponse("", entetes={"HX-Redirect": self.prefixe + "/"}) def groupes_existants(self): res = [] for d in sorted(os.listdir(self.racine)): c = os.path.join(self.racine, d) if os.path.isdir(c) and not d.startswith((".", "_")) and d not in site.IGNORES \ and not site.est_voyage(c): res.append(d) return res def nouveau(self, r): return page("nouveau.html", **self.contexte(groupes=self.groupes_existants())) def nouveau_creer(self, r): dossier = r.f("dossier").strip() groupe = r.f("groupe_nouveau").strip() or r.f("groupe").strip() if not dossier: return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(), erreur="Indiquez un nom de dossier.", form=r.form)) try: chemin = site.creer_voyage(self.racine, dossier, r.f("titre").strip(), groupe, r.f("moto").strip()) except (ValueError, OSError) as e: return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(), erreur=str(e), form=r.form)) rel = os.path.relpath(chemin, self.racine).replace(os.sep, "/") return Reponse("", entetes={"HX-Redirect": f"{self.prefixe}/voyage?v={quote(rel)}#sources"}) # --- Un voyage def voyage(self, r): chemin = self.chemin_voyage(r.q("v")) cfg = self.cfg_voyage(chemin) recit_f = os.path.join(chemin, "voyage.md") recit = open(recit_f, encoding="utf-8").read() if os.path.exists(recit_f) else "" return page("voyage.html", **self.contexte( v=self.fiche_voyage(chemin), cfg=cfg, recit=recit, sources=self.etat_sources(chemin, cfg))) def voyage_infos(self, r): chemin = self.chemin_voyage(r.f("v")) self.toml_voyage(chemin, {"titre": r.f("titre").strip() or None, "moto": r.f("moto").strip() or None}) return Reponse('✓ Enregistré. Les pages seront à jour à la prochaine génération.') def voyage_recit(self, r): chemin = self.chemin_voyage(r.f("v")) texte = r.f("recit").replace("\r\n", "\n") f = os.path.join(chemin, "voyage.md") if texte.strip() or os.path.exists(f): with open(f, "w", encoding="utf-8") as fh: fh.write(texte) return Reponse('✓ Récit enregistré.') def etat_sources(self, chemin, cfg): res = {} for cle, (libelle, ext, _) in CLES_SOURCES.items(): lignes = [] for s in cfg["sources"].get(cle, []): p = os.path.join(chemin, os.path.expanduser(s)) fichiers = lister_fichiers([s], ext, chemin) if os.path.exists(p) else [] lignes.append({"source": s, "absolu": os.path.abspath(p), "existe": os.path.exists(p), "lien_casse": os.path.islink(p) and not os.path.exists(p), "nb": len(fichiers)}) res[cle] = {"libelle": libelle, "lignes": lignes} return res def voyage_sources(self, r): chemin = self.chemin_voyage(r.f("v")) cle = r.f("cle") if cle not in CLES_SOURCES: raise Erreur(400, "Type de source inconnu") cfg = self.cfg_voyage(chemin) liste = list(cfg["sources"].get(cle, [])) source = r.f("chemin").strip() if r.f("action") == "ajouter" and source: source = os.path.abspath(os.path.expanduser(source)) if not os.path.exists(source): raise Erreur(400, f"Introuvable : {source}") # Dans le dossier du voyage : chemin relatif (le site reste déplaçable). if os.path.realpath(source).startswith(os.path.realpath(chemin) + os.sep): source = os.path.relpath(os.path.realpath(source), os.path.realpath(chemin)) if source not in liste: liste.append(source) elif r.f("action") == "retirer": liste = [s for s in liste if s != source] self.toml_voyage(chemin, {"sources": {cle: liste}}) cfg = self.cfg_voyage(chemin) rel = r.f("v") return Reponse(ENV.get_template("_sources.html").render( v={"chemin": rel}, sources=self.etat_sources(chemin, cfg)), entetes={"HX-Trigger": "sources-modifiees"}) def _empreinte(self, chemin, cfg): """Ce dont dépend l'analyse : si rien n'a changé, on la réutilise.""" def etat(f): try: st = os.stat(f) return (f, st.st_mtime, st.st_size) except OSError: return (f, None, None) fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin) 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) cle = self._empreinte(chemin, cfg) if self._cache.get(chemin, (None,))[0] == cle: return self._cache[chemin][1] res = self._analyse(chemin, cfg) self._cache[chemin] = (cle, res) return res def _analyse(self, chemin, cfg): a = sources.analyser(chemin, cfg) jours = [] for i, j in enumerate(sorted(a.journees)): noms = sorted({d["nom"] for d in a.details_traces if j in d["jours"]}) jours.append({"date": j, "km": a.km(j), "traces": noms, "couleur": COULEURS[i % len(COULEURS)]}) suggestions = {} if a.non_datees: medias = sources.positions_medias(chemin, a) for f, pistes in a.non_datees.items(): suggestions[os.path.basename(f)] = sources.suggerer_jour(pistes, medias) return cfg, a, jours, suggestions def voyage_analyse(self, r): chemin = self.chemin_voyage(r.q("v")) cfg, a, jours, suggestions = self.analyse(chemin) total = sum(j["km"] for j in jours) return page("_analyse.html", v={"chemin": r.q("v")}, a=a, jours=jours, total_km=total, suggestions=suggestions, cfg=cfg, couches=_couches(a, jours), non_datees=sorted(os.path.basename(f) for f in a.non_datees)) def voyage_date(self, r): chemin = self.chemin_voyage(r.f("v")) nom, jour = r.f("fichier"), r.f("jour").strip() if jour and not sources.jour_texte(jour): raise Erreur(400, f"Date invalide : {jour}") self.toml_voyage(chemin, {"dates_traces": {nom: jour or None}}) return self._rafraichir_analyse(r.f("v")) def voyage_dates_suite(self, r): chemin = self.chemin_voyage(r.f("v")) debut = sources.jour_texte(r.f("debut")) if not debut: raise Erreur(400, "Indiquez le premier jour") _, a, _, _ = self.analyse(chemin) # Toutes les traces qui ne se datent pas seules, même déjà rattachées. noms = sorted(d["nom"] for d in a.details_traces if not d["erreur"] and not d["datable"]) self.toml_voyage(chemin, {"dates_traces": {n: (debut + timedelta(days=i)).isoformat() for i, n in enumerate(noms)}}) return self._rafraichir_analyse(r.f("v")) def _rafraichir_analyse(self, rel): return self.voyage_analyse(Requete({"v": rel}, {})) def voyage_infos_utiles(self, r): chemin = self.chemin_voyage(r.q("v")) cfg = self.cfg_voyage(chemin) a = sources.analyser(chemin, cfg, leger=True) def taille_dossier(d): d = os.path.join(chemin, d) if not os.path.isdir(d): return 0 return sum(e.stat().st_size for e in os.scandir(d) if e.is_file() and not est_cache(e.name)) rushs = sum(os.path.getsize(f) for f in a.videos if os.path.exists(f)) infos = { "rushs": rushs, "nb_rushs": len(a.videos), "videos_web": taille_dossier("videos"), "photos": taille_dossier("photos"), "photos_web": taille_dossier("photos_web"), "apercus": taille_dossier("apercus"), "libre": shutil.disk_usage(chemin).free, "encodage": parametres_video(cfg), } reste = [f for f in a.videos if not video.deja_faite(chemin, f, rapide=True)] infos["a_encoder"] = len(reste) 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"{self.prefixe}/site/{quote(rel)}/{dossier}/{quote(nom)}" else: base = os.path.splitext(nom)[0] ap = os.path.join(chemin, "apercus", base + ".jpg") vignette = f"{self.prefixe}/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("~") chemin = os.path.abspath(os.path.expanduser(chemin)) if not os.path.isdir(chemin): chemin = os.path.dirname(chemin) cle = r.q("cle") ext = CLES_SOURCES.get(cle, ("", (), ""))[1] sous, nb = [], 0 try: for e in sorted(os.scandir(chemin), key=lambda e: e.name.lower()): if est_cache(e.name): continue if e.is_dir(): sous.append(e.name) elif ext and e.name.lower().endswith(ext): nb += 1 except PermissionError: pass raccourcis = [("Dossier personnel", os.path.expanduser("~")), ("Site", self.racine)] if r.q("v"): raccourcis.append(("Ce voyage", os.path.join(self.racine, r.q("v")))) if platform.system() == "Darwin" and os.path.isdir("/Volumes"): raccourcis += [(d, os.path.join("/Volumes", d)) for d in sorted(os.listdir("/Volumes")) if not d.startswith(".")] elif platform.system() == "Windows": raccourcis += [(f"{l}:", f"{l}:\\") for l in string.ascii_uppercase if os.path.exists(f"{l}:\\")] else: for base in ("/media", "/mnt", f"/media/{os.environ.get('USER', '')}"): if os.path.isdir(base): raccourcis += [(d, os.path.join(base, d)) for d in sorted(os.listdir(base)) if os.path.isdir(os.path.join(base, d)) and not d.startswith(".")] parent = os.path.dirname(chemin) return page("_dossiers.html", chemin=chemin, parent=parent if parent != chemin else None, sous=sous, nb=nb, cle=cle, libelle=CLES_SOURCES.get(cle, ("", (), "fichiers"))[2], v=r.q("v"), raccourcis=raccourcis, sep=os.sep) # --- Réglages du site def reglages(self, r): return page("reglages.html", **self.contexte(cfg=reglages_site(self.racine))) def reglages_enregistrer(self, r): def entier(k, defaut): try: return int(r.f(k)) except ValueError: return defaut cfg = reglages_site(self.racine) liens = [l.strip() for l in r.f("mymaps").splitlines() if l.strip()] modifier_toml(os.path.join(self.racine, SITE_TOML), { "titre": r.f("titre").strip() or cfg["titre"], "adresse_publique": r.f("adresse_publique").strip(), "accueil_nb_recents": entier("accueil_nb_recents", 2), "video": {"qualite": r.f("qualite") if r.f("qualite") in QUALITES else "equilibree", "encodeur": "materiel" if r.f("encodeur") == "materiel" else "processeur", "preset": r.f("preset") or "medium", "jobs": entier("jobs", 0)}, "photos": {"grand_cote": entier("grand_cote", 1600), "qualite": entier("qualite_photos", 80), "positions_deduites": r.f("positions_deduites") == "on"}, "journees": {"fuseau": r.f("fuseau").strip() or "Europe/Paris", "coupure_heure": entier("coupure_heure", 4)}, "carte_globale": {"mymaps": liens}, "administration": {"adresse": r.f("administration").strip()}, }, entete="Réglages du site RoadTripsGenerator (rtg).") return Reponse('✓ Réglages enregistrés.') def systeme(self, r): from rtg.verifier import verifier tampon = io.StringIO() with redirect_stdout(tampon): ok = verifier() libre = shutil.disk_usage(self.racine) return page("_systeme.html", lignes=tampon.getvalue().splitlines(), ok=ok, libre=libre.free, total=libre.total, racine=self.racine) # --- Traitements def tache_lancer(self, r): quoi, rel = r.f("quoi"), r.f("v") compression = r.f("sans_compression") != "on" force = r.f("force") == "on" racine = self.racine if quoi in ("generer", "compresser"): chemin = self.chemin_voyage(rel) if quoi == "generer": libelle = f"Génération de {rel}" + ("" if compression else " (sans les vidéos)") def fn(suivi): return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi) elif quoi == "compresser": libelle = f"Compression des vidéos de {rel}" def fn(suivi): cfg = reglages_voyage(chemin, reglages_site(racine)) suivi({"etape": "Vidéos", "voyage": rel}) return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=force, progression=lambda n, e, p, rg: suivi( {"video": n, "etat": e, "pct": p, "rang": rg})) elif quoi == "tout": libelle = "Génération de tout le site" + ("" if compression else " (sans les vidéos)") def fn(suivi): return site.generer(racine, None, compression=compression, force=force, suivi=suivi) elif quoi == "accueil": libelle = "Page d'accueil et carte globale" def fn(suivi): suivi({"etape": "Accueil"}) accueil.generer(racine, reglages_site(racine), r.f("mymaps_maj") == "on") else: raise Erreur(400, "Traitement inconnu") t = taches.lancer(libelle, fn, rel if quoi in ("generer", "compresser") else "") message = None if t else "Un traitement est déjà en cours : attendez qu'il se termine ou arrêtez-le." return page("_tache.html", tache=taches.COURANTE, message=message) def tache_arreter(self, r): # La relève suivante (chaque seconde) affichera l'arrêt : remplacer le # bloc ici en même temps qu'elle ferait échouer l'une des deux. taches.arreter() return Reponse("") def tache(self, r): return page("tache.html", **self.contexte()) def tache_fragment(self, r): t = taches.COURANTE rep = page("_tache.html", tache=t, complet=r.q("complet") == "1") if t and not t.en_cours: # Dernière relève : les autres zones de la page se mettent à jour. rep.entetes["HX-Trigger"] = "tache-finie" return rep def tache_bandeau(self, r): return page("_bandeau.html", tache=taches.COURANTE) # --- HTTP ---------------------------------------------------------------------- def _couches(a, jours, maxi=1500): """Traces de chaque journée pour la carte (au plus `maxi` points par jour).""" def coords(pistes): segments = [s.points for t in pistes for s in t.segments if len(s.points) > 1] pas = max(1, sum(len(s) for s in segments) // maxi) res = [] for pts in segments: seg = [[round(p.latitude, 5), round(p.longitude, 5)] for p in pts[::pas]] if (len(pts) - 1) % pas: seg.append([round(pts[-1].latitude, 5), round(pts[-1].longitude, 5)]) res.append(seg) return res couches = [{"nom": _date_longue(j["date"]), "km": round(j["km"]), "couleur": j["couleur"], "segments": coords(a.journees[j["date"]])} for j in jours] for f, pistes in a.non_datees.items(): couches.append({"nom": f"{os.path.basename(f)} (sans jour)", "couleur": "#555", "pointille": True, "km": 0, "segments": coords(pistes)}) return couches def _valeur(d, k, defaut): v = d.get(k, defaut) if isinstance(v, list): return v[0] if v else defaut return v class Requete: def __init__(self, query, form): self.query, self.form = query, form def q(self, k, defaut=""): return _valeur(self.query, k, defaut) def f(self, k, defaut=""): return _valeur(self.form, k, defaut) # 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, prefixe=app.prefixe) hotes = {f"127.0.0.1:{port}", f"localhost:{port}"} P = app.prefixe # « Voir le site » : derrière le serveur web, le vrai site ; sinon le site servi par RTG. ENV.globals["lien_site"] = (P.rsplit("/", 1)[0] + "/") if gardien.proxy else P + "/site/" class Gestionnaire(BaseHTTPRequestHandler): server_version = f"rtg/{__version__}" sys_version = "" def log_message(self, fmt, *args): pass # le terminal sert au journal des traitements def do_GET(self): self.traiter("GET") def do_HEAD(self): self.traiter("GET", tete=True) 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 ip(self): """Adresse du visiteur ; derrière le serveur web, celle qu'il transmet.""" if gardien.proxy: transmise = (self.headers.get("X-Forwarded-For") or "").split(",")[-1].strip() if transmise: return transmise return self.client_address[0] def traiter(self, methode, tete=False): try: if not (gardien.distant or gardien.proxy) and self.headers.get("Host") not in hotes: raise Erreur(403, "Hôte refusé") url = urlsplit(self.path) chemin = unquote(url.path) if P: if chemin == P: return self.rediriger(P + "/") if not chemin.startswith(P + "/"): raise Erreur(404, "Page introuvable") chemin = chemin[len(P):] query = parse_qs(url.query, keep_blank_values=True) if methode == "GET" and chemin.startswith("/static/"): return self.fichier(STATIC, chemin[len("/static/"):], 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(P + chemin, gardien.entete_cookie(sid, self.https())) return self.rediriger(P + "/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) 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": P + "/connexion"}), tete) return self.rediriger(P + "/connexion") if chemin == "/deconnexion" and methode == "POST": gardien.fermer_session(self.session()) return self.envoyer(Reponse("", entetes={ "HX-Redirect": P + "/connexion", "Set-Cookie": gardien.entete_cookie("", self.https(), 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) break else: raise Erreur(404, "Page introuvable") except Erreur as e: rep = Reponse(f'
{_echapper(e.message)}
', code=e.code) if self.headers.get("HX-Request") == "true": # htmx n'affiche pas les réponses en erreur : on renvoie 200 # vers la zone de messages. rep.code = 200 rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"} except Exception as e: import traceback traceback.print_exc() rep = Reponse(f'
Erreur : {_echapper(str(e))}
', code=500) if self.headers.get("HX-Request") == "true": rep.code = 200 rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"} self.envoyer(rep, tete) def https(self): return gardien.https or (gardien.proxy and self.headers.get("X-Forwarded-Proto") == "https") def connexion(self, methode, form, connecte): ip = self.ip() if methode == "POST": ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", "")) if ok: sid = gardien.ouvrir_session() return Reponse("", entetes={"HX-Redirect": P + "/", "Set-Cookie": gardien.entete_cookie(sid, self.https())}) return Reponse(f'
{_echapper(message)}
') return page("connexion.html", mdp=acces.defini(), connecte=connecte, distant=gardien.distant or gardien.proxy, attente=gardien.attente(ip), site=self.adresse_site()) def adresse_site(self): """Lien « Retour au site » de la page de connexion, derrière le serveur web.""" return (P.rsplit("/", 1)[0] or "") + "/" if gardien.proxy else "" 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() if not tete: self.wfile.write(rep.corps) def fichier(self, base, rel, tete): """Fichier statique, avec les requêtes partielles (Range) dont Safari a besoin pour lire les vidéos.""" base = os.path.realpath(base) chemin = os.path.realpath(os.path.join(base, rel)) if chemin != base and not chemin.startswith(base + os.sep): raise Erreur(403, "Chemin refusé") if os.path.isdir(chemin): if not self.path.split("?")[0].endswith("/"): self.send_response(301) self.send_header("Location", self.path.split("?")[0] + "/") self.end_headers() return chemin = os.path.join(chemin, "index.html") if not os.path.isfile(chemin): return self.envoyer(Reponse("Fichier introuvable", "text/plain; charset=utf-8", 404), tete) taille = os.path.getsize(chemin) type_ = mimetypes.guess_type(chemin)[0] or "application/octet-stream" if type_.startswith("text/") or type_ in ("application/javascript", "application/json"): type_ += "; charset=utf-8" debut, fin, code = 0, taille - 1, 200 m = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", "")) if m and taille: if m.group(1): debut = int(m.group(1)) fin = min(int(m.group(2)), taille - 1) if m.group(2) else taille - 1 else: debut = max(0, taille - int(m.group(2) or 0)) if debut > fin: self.send_response(416) self.send_header("Content-Range", f"bytes */{taille}") self.end_headers() return code = 206 self.send_response(code) self.send_header("Content-Type", type_) self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(fin - debut + 1 if taille else 0)) if code == 206: self.send_header("Content-Range", f"bytes {debut}-{fin}/{taille}") self.end_headers() if tete or not taille: return try: with open(chemin, "rb") as f: f.seek(debut) reste = fin - debut + 1 while reste > 0: bloc = f.read(min(1 << 20, reste)) if not bloc: break self.wfile.write(bloc) reste -= len(bloc) except (BrokenPipeError, ConnectionResetError): pass # le navigateur a interrompu (vidéo) return Gestionnaire def _echapper(s): return str(s).replace("&", "&").replace("<", "<").replace(">", ">") def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None, proxy=False, prefixe=""): """Lance l'interface. distant : écoute sur toutes les interfaces réseau, mot de passe obligatoire ; certificat, cle : HTTPS ; proxy : derrière le serveur web du site (qui transmet https://…/admin/ à 127.0.0.1:port), mot de passe obligatoire ; prefixe : « /admin ».""" if (distant or proxy) and not acces.defini(): raise SystemExit("❌ L'accès depuis le réseau 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, prefixe) serveur = None # Derrière le serveur web, le port doit être exactement celui de sa configuration. for p in ([port] if proxy else range(port, port + 20)): try: gardien = Gardien(p, distant=distant, https=bool(contexte_tls), proxy=proxy, prefixe=app.prefixe) 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"❌ Port {port} occupé." if proxy else 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 schema = "https" if contexte_tls else "http" url = f"{schema}://127.0.0.1:{port}{app.prefixe}/?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 proxy: print(f" Derrière le serveur web : https://{app.prefixe}/ (mot de passe)") 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: threading.Timer(0.5, lambda: webbrowser.open(url)).start() try: serveur.serve_forever() except KeyboardInterrupt: taches.arreter() print("\nInterface arrêtée.") finally: serveur.server_close()