Files
RoadTripsGenerator/rtg/interface/serveur.py
T

947 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 mimetypes
import os
import platform
import re
import shutil
import signal
import ssl
import string
import threading
import time
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.derniere_activite = time.time()
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('<span class="ok">✓ Enregistré. Les pages seront à jour à la prochaine génération.</span>')
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('<span class="ok">✓ Récit enregistré.</span>')
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"))))
# /Volumes : disques du Mac, y compris vus depuis le conteneur Docker.
if 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('<span class="ok">✓ Réglages enregistrés.</span>')
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):
# Activité, pour l'arrêt automatique : les relèves périodiques d'un
# onglet resté ouvert ne comptent pas.
if not self.path.split("?")[0].endswith(("/tache/bandeau", "/tache/fragment")):
app.derniere_activite = time.time()
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":
# La connexion marche aussi sans JavaScript (formulaire classique),
# à condition de venir d'une page de ce site.
if self.headers.get("HX-Request") != "true" and not (
chemin == "/connexion" and self.meme_origine()):
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:
rep = self.connexion(methode, form, connecte)
return self.envoyer(rep, tete) if rep else None
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'<div class="erreur">{_echapper(e.message)}</div>', 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'<div class="erreur">Erreur : {_echapper(str(e))}</div>', 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 meme_origine(self):
origine = self.headers.get("Origin")
if not origine:
return True # navigateurs anciens : pas d'en-tête Origin
hote = self.headers.get("X-Forwarded-Host") if gardien.proxy else None
return urlsplit(origine).netloc in {hote or self.headers.get("Host", ""), *hotes}
def connexion(self, methode, form, connecte):
ip = self.ip()
htmx = self.headers.get("HX-Request") == "true"
message = ""
if methode == "POST":
mdp = _valeur(form, "mot_de_passe", "")
ok, message = gardien.essayer(ip, mdp)
if ok:
print(f"🔓 Connexion à l'interface depuis {ip}")
sid = gardien.ouvrir_session()
cookie = gardien.entete_cookie(sid, self.https())
if htmx:
return Reponse("", entetes={"HX-Redirect": P + "/", "Set-Cookie": cookie})
self.rediriger(P + "/", cookie)
return None
# Dans le Terminal, pour comprendre un refus (jamais le mot de passe).
raison = "champ vide (formulaire mal transmis ?)" if not mdp else message
print(f"🔒 Connexion refusée depuis {ip} : {raison}")
if htmx:
return Reponse(f'<div class="erreur">{_echapper(message)}</div>')
return page("connexion.html", mdp=acces.defini(), connecte=connecte,
distant=gardien.distant or gardien.proxy, attente=gardien.attente(ip),
site=self.adresse_site(), message=message)
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None,
proxy=False, prefixe="", ecoute=None, arret_inactif=0):
"""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 » ;
ecoute : adresse d'écoute imposée (« 0.0.0.0 » si le serveur web est sur
une autre machine) ; arret_inactif : minutes sans activité (ni traitement en
cours) au bout desquelles l'interface s'arrête d'elle-même, 0 = jamais."""
hors_machine = ecoute and ecoute not in ("127.0.0.1", "localhost", "::1")
if (distant or proxy or hors_machine) 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)
adresse = ecoute or ("0.0.0.0" if distant else "127.0.0.1")
serveur = ThreadingHTTPServer((adresse, 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://<votre site>{app.prefixe}/ (mot de passe)")
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:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
# « docker stop » envoie SIGTERM : on s'arrête aussitôt, comme sur Ctrl+C.
def sur_sigterm(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, sur_sigterm)
if arret_inactif:
print(f" Arrêt automatique après {arret_inactif:g} min sans activité.")
def surveiller():
while True:
time.sleep(min(30, arret_inactif * 30))
t = taches.COURANTE
if t and t.en_cours:
app.derniere_activite = time.time()
elif time.time() - app.derniere_activite > arret_inactif * 60:
print(f"💤 Aucune activité depuis {arret_inactif:g} min : arrêt de l'interface.")
serveur.shutdown()
return
threading.Thread(target=surveiller, daemon=True, name="rtg-veille").start()
try:
serveur.serve_forever()
except KeyboardInterrupt:
taches.arreter()
print("\nInterface arrêtée.")
finally:
serveur.server_close()