From 71779f6f53e6779d12b9bc8b52dcff1f6136c02a Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 9 Oct 2026 14:35:43 +0000 Subject: [PATCH] =?UTF-8?q?Interface=20:=20corrections=20=C3=A0=20la=20sou?= =?UTF-8?q?ris=20et=20acc=C3=A8s=20prot=C3=A9g=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim --- README.md | 18 +- rtg/acces.py | 73 +++++++ rtg/cli.py | 28 ++- rtg/config.py | 3 + rtg/corrections.py | 107 ++++++++++ rtg/extraction.py | 32 ++- rtg/interface/modeles/base.html | 1 + rtg/interface/modeles/connexion.html | 37 ++++ rtg/interface/modeles/voyage.html | 37 ++++ rtg/interface/securite.py | 93 +++++++++ rtg/interface/serveur.py | 218 +++++++++++++++++++-- rtg/interface/static/editeur.js | 282 +++++++++++++++++++++++++++ rtg/interface/static/rtg.css | 40 ++++ rtg/interface/static/rtg.js | 1 + rtg/pages.py | 31 ++- rtg/site.py | 2 +- tests/test_interface.py | 82 +++++++- 17 files changed, 1047 insertions(+), 38 deletions(-) create mode 100644 rtg/acces.py create mode 100644 rtg/corrections.py create mode 100644 rtg/interface/modeles/connexion.html create mode 100644 rtg/interface/securite.py create mode 100644 rtg/interface/static/editeur.js diff --git a/README.md b/README.md index c3d8b65..e6b0b0d 100644 --- a/README.md +++ b/README.md @@ -121,17 +121,30 @@ cd /Volumes/CLEROADTRIPS/Road-Trips rtg interface ``` -Le navigateur s'ouvre sur l'interface de RTG (adresse `http://127.0.0.1:8765/`, visible de cet ordinateur seulement). Laisser la fenêtre de Terminal ouverte pendant l'utilisation ; Ctrl+C l'arrête. +Le navigateur s'ouvre sur l'interface de RTG (adresse `http://127.0.0.1:8765/…`, visible de cet ordinateur seulement). Laisser la fenêtre de Terminal ouverte pendant l'utilisation ; Ctrl+C l'arrête. - **Mes voyages** : tous les voyages par rubrique, avec leurs dates et l'avancement des vidéos, des photos et des pages. Le bouton « Nouveau voyage » crée le dossier et son `voyage.toml`. -- **Un voyage**, en quatre onglets : +- **Un voyage**, en cinq onglets : - *Journées* : la carte des traces, jour par jour, et les kilomètres. Une trace sans heures et sans date dans son nom (un itinéraire préparé, par exemple) se rattache ici à un jour. RTG propose le jour où des photos ou des vidéos ont été prises le long de la trace. On peut aussi répartir les traces à la suite, un jour par trace. + - *Carte et corrections* : glisser une photo ou une vidéo mal placée, placer celles qui n'ont pas de position (cliquer son nom puis la carte), et masquer des zones de trace : autour de chez soi, ou sur une erreur du GPS. Une zone peut valoir pour tous les voyages (zone privée). Fond plan ou satellite. Les corrections sont notées dans `voyage.toml` (`[positions]`, `[corrections]`) ou `site.toml` (`[confidentialite]`) et appliquées à la génération suivante ; les fichiers d'origine ne sont pas modifiés. - *Sources* : où sont les traces, les vidéos et les photos d'origine, choisies dans une fenêtre de dossiers ou collées. Le nombre de fichiers trouvés s'affiche, et un disque débranché est signalé. - *Infos et récit* : titre, moto et récit (`voyage.md`). - *Générer* : tailles des rushs et de ce qui reste à encoder, espace libre, puis lancement de la génération ou de l'encodage seul. L'avancement de chaque vidéo s'affiche ; « Arrêter » interrompt proprement, sans perdre les vidéos déjà finies. - **Réglages** : les réglages de `site.toml` (qualité vidéo, taille des photos, fuseau, cartes My Maps) et la vérification des outils installés. - **Voir le site** : le site généré, servi tel quel, vidéos comprises. +### Accès à l'interface + +L'interface permet de modifier le site et de lire les fichiers de l'ordinateur : elle est protégée. + +- **Sur l'ordinateur où elle tourne**, l'adresse ouverte au lancement contient une clé d'accès tirée au hasard (`…/?jeton=…`), affichée aussi dans le Terminal. Sans elle, l'interface demande de se connecter. La clé change à chaque lancement ; le navigateur garde ensuite une session de 12 heures, perdue quand l'interface s'arrête. « Se déconnecter » la ferme tout de suite. +- **Mot de passe** : `rtg mot-de-passe` le définit (10 caractères au moins). Seule une empreinte est enregistrée, hors du site : `~/.config/rtg/acces.toml` (`%APPDATA%\rtg\acces.toml` sous Windows). `rtg mot-de-passe --supprimer` l'efface. Après 5 essais faux, l'adresse IP doit attendre une minute, puis deux, quatre… jusqu'à une heure. +- **Depuis une autre machine** (le serveur qui héberge le site, par exemple) : `rtg interface --distant`, qui exige le mot de passe. Sans HTTPS, ce mot de passe circulerait en clair. Trois solutions : + - servir l'interface derrière le serveur web du site, en HTTPS, sur une adresse à part ; + - `--certificat cert.pem --cle cle.pem` ; + - le plus simple : un tunnel SSH. Sur le serveur, lancer `rtg interface --sans-navigateur` (sans `--distant`). Sur le Mac, ouvrir `ssh -L 8765:127.0.0.1:8765 serveur`, puis l'adresse avec la clé affichée sur le serveur. +- Les fichiers de réglages (`site.toml`, `voyage.toml`) sont dans les dossiers du site. Si le serveur web publie ce dossier tel quel, ils sont lisibles par tous, avec les chemins de tes disques. Rien de secret n'y est écrit, mais on peut les refuser au serveur web (par exemple `location ~ \.toml$ { deny all; }` pour nginx, ou ` Require all denied ` pour Apache). + Tout ce que fait l'interface s'écrit dans `site.toml` et les `voyage.toml`. On peut toujours utiliser les commandes ci-dessous ou modifier ces fichiers à la main. Une réécriture par l'interface conserve les valeurs mais pas les commentaires. ## Utilisation : la commande `rtg` @@ -146,6 +159,7 @@ rtg generer --sans-compression # tous les voyages, sans encoder de vidéo rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources rtg verifier # tout est-il installé ? rtg interface # l'interface dans le navigateur +rtg mot-de-passe # mot de passe de l'interface (accès distant) ``` Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`. diff --git a/rtg/acces.py b/rtg/acces.py new file mode 100644 index 0000000..bfffc3e --- /dev/null +++ b/rtg/acces.py @@ -0,0 +1,73 @@ +"""Mot de passe de l'interface (`rtg mot-de-passe`). + +Il est rangé hors du site (le dossier du site est souvent servi tel quel par +le serveur web) : ~/.config/rtg/acces.toml sous macOS et Linux, +%APPDATA%\\rtg\\acces.toml sous Windows. Seule une empreinte scrypt salée y +est écrite, jamais le mot de passe. +""" +import base64 +import hashlib +import hmac +import os +import secrets + +from rtg.config import ecrire_toml, lire_toml + +LONGUEUR_MIN = 10 +SCRYPT = {"n": 2 ** 15, "r": 8, "p": 1} + + +def chemin_acces(): + if os.name == "nt": + base = os.environ.get("APPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config") + return os.path.join(base, "rtg", "acces.toml") + + +def _empreinte(mdp, sel, n, r, p): + return hashlib.scrypt(mdp.encode("utf-8"), salt=sel, n=n, r=r, p=p, + maxmem=128 * r * n * 2, dklen=32) + + +def definir(mdp): + if len(mdp) < LONGUEUR_MIN: + raise ValueError(f"Le mot de passe doit faire au moins {LONGUEUR_MIN} caractères.") + sel = secrets.token_bytes(16) + e = _empreinte(mdp, sel, **SCRYPT) + chemin = chemin_acces() + os.makedirs(os.path.dirname(chemin), exist_ok=True) + ecrire_toml(chemin, {"mot_de_passe": "scrypt${n}${r}${p}${sel}${e}".format( + **SCRYPT, sel=base64.b64encode(sel).decode(), e=base64.b64encode(e).decode())}, + entete="Accès à l'interface RTG (empreinte du mot de passe, pas le mot de passe).") + try: + os.chmod(chemin, 0o600) + except OSError: + pass + return chemin + + +def defini(): + return bool(lire_toml(chemin_acces()).get("mot_de_passe")) + + +def supprimer(): + chemin = chemin_acces() + if os.path.exists(chemin): + os.remove(chemin) + return True + return False + + +def verifier(mdp): + """Vrai si `mdp` est le mot de passe enregistré (comparaison à temps constant).""" + valeur = lire_toml(chemin_acces()).get("mot_de_passe", "") + try: + algo, n, r, p, sel, e = valeur.split("$") + if algo != "scrypt": + return False + attendu = base64.b64decode(e) + calcule = _empreinte(mdp, base64.b64decode(sel), int(n), int(r), int(p)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(attendu, calcule) diff --git a/rtg/cli.py b/rtg/cli.py index d8246bf..8266b47 100644 --- a/rtg/cli.py +++ b/rtg/cli.py @@ -8,6 +8,7 @@ rtg migrer écrire site.toml et les voyage.toml depuis l'existant rtg verifier tout ce dont RTG a besoin est-il installé ? rtg interface ouvrir l'interface dans le navigateur + rtg mot-de-passe définir le mot de passe de l'interface (accès distant) La racine du site est le premier dossier, en remontant depuis le dossier courant, qui contient site.toml (sinon le dossier courant) ; --racine la force. @@ -49,9 +50,31 @@ def main(argv=None): i = sp.add_parser("interface", help="ouvrir l'interface dans le navigateur") i.add_argument("--port", type=int, default=8765) i.add_argument("--sans-navigateur", action="store_true", help="ne pas ouvrir le navigateur") + i.add_argument("--distant", action="store_true", + help="accepter les connexions depuis le réseau (mot de passe obligatoire)") + i.add_argument("--certificat", help="certificat TLS (PEM) pour servir en HTTPS") + i.add_argument("--cle", help="clé privée du certificat TLS (PEM)") + + mp = sp.add_parser("mot-de-passe", help="définir ou supprimer le mot de passe de l'interface") + mp.add_argument("--supprimer", action="store_true") args = p.parse_args(argv) + if args.commande == "mot-de-passe": + from rtg import acces + if args.supprimer: + print("✅ Mot de passe supprimé." if acces.supprimer() else "ℹ️ Aucun mot de passe défini.") + return + import getpass + mdp = getpass.getpass("Nouveau mot de passe : ") + if mdp != getpass.getpass("Le même, encore une fois : "): + sys.exit("❌ Les deux saisies diffèrent.") + try: + print(f"✅ Mot de passe enregistré ({acces.definir(mdp)}).") + except ValueError as e: + sys.exit(f"❌ {e}") + return + if args.commande == "verifier": # Avant tout autre import : doit fonctionner même si un module manque. from rtg.verifier import verifier @@ -80,7 +103,10 @@ def main(argv=None): site.migrer(racine) elif args.commande == "interface": from rtg.interface.serveur import lancer - lancer(racine, args.port, ouvrir=not args.sans_navigateur) + if bool(args.certificat) != bool(args.cle): + sys.exit("❌ --certificat et --cle vont ensemble.") + lancer(racine, args.port, ouvrir=not args.sans_navigateur, distant=args.distant, + certificat=args.certificat, cle=args.cle) except KeyboardInterrupt: print("\nInterrompu.") sys.exit(130) diff --git a/rtg/config.py b/rtg/config.py index 29ceee6..fb293b9 100644 --- a/rtg/config.py +++ b/rtg/config.py @@ -38,6 +38,9 @@ DEFAUTS = { "sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]}, "exclusions": {"fichiers": []}, "dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ) + "positions": {}, # média placé à la main → [lat, lon] + "corrections": {"masques": []}, # zones retirées des traces : [lat, lon, rayon_m] + "confidentialite": {"zones": []}, # (site) zones retirées de tous les voyages } # Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages diff --git a/rtg/corrections.py b/rtg/corrections.py new file mode 100644 index 0000000..20f0998 --- /dev/null +++ b/rtg/corrections.py @@ -0,0 +1,107 @@ +"""Corrections faites à la main (interface, onglet « Carte ») et appliquées à +la génération des pages ; les fichiers d'origine ne sont jamais modifiés. + +voyage.toml + [positions] média déplacé ou placé à la main + "IMG_4831.jpeg" = [48.85, 2.35] + [corrections] + masques = [[48.85, 2.35, 300]] zones (centre, rayon en m) retirées des traces + +site.toml + [confidentialite] + zones = [[48.80, 2.10, 500]] zones retirées des traces de tous les voyages + (autour de chez soi, par exemple) +""" +import csv +import math +import os + +import gpxpy.gpx + + +def masques(cfg): + """[(lat, lon, rayon_m)] : zones du voyage et zones privées du site.""" + res = [] + for liste in (cfg.get("corrections", {}).get("masques", []), + cfg.get("confidentialite", {}).get("zones", [])): + for m in liste: + try: + res.append((float(m[0]), float(m[1]), float(m[2]))) + except (TypeError, ValueError, IndexError): + pass + return res + + +def positions(cfg): + """{nom de fichier: (lat, lon)} des médias placés à la main.""" + res = {} + for nom, pos in cfg.get("positions", {}).items(): + try: + res[nom] = (float(pos[0]), float(pos[1])) + except (TypeError, ValueError, IndexError): + pass + return res + + +def distance_m(lat1, lon1, lat2, lon2): + x = math.radians(lon2 - lon1) * math.cos(math.radians((lat1 + lat2) / 2)) + y = math.radians(lat2 - lat1) + return 6371000 * math.hypot(x, y) + + +def dans_un_masque(lat, lon, zones): + return any(distance_m(lat, lon, m[0], m[1]) <= m[2] for m in zones) + + +def filtrer_piste(piste, zones): + """Retire d'une piste GPX les points situés dans les zones ; un segment + traversé par une zone est coupé en deux. Renvoie le nombre de points retirés.""" + if not zones: + return 0 + retires = 0 + nouveaux = [] + for seg in piste.segments: + courant = gpxpy.gpx.GPXTrackSegment() + for p in seg.points: + if dans_un_masque(p.latitude, p.longitude, zones): + retires += 1 + if courant.points: + nouveaux.append(courant) + courant = gpxpy.gpx.GPXTrackSegment() + else: + courant.points.append(p) + if courant.points: + nouveaux.append(courant) + piste.segments = [s for s in nouveaux if len(s.points) > 1] + return retires + + +def lire_medias(voyage, cfg): + """Photos et vidéos des exports CSV, positions corrigées comprises. + + Renvoie une liste de dicts {fichier, type, date, lat, lon, deplace} ; lat et + lon valent None pour un média sans position.""" + corr = positions(cfg) + res = [] + for nom_csv, type_ in (("export_photos.csv", "photo"), ("export_videos.csv", "video")): + chemin = os.path.join(voyage, nom_csv) + if not os.path.exists(chemin): + continue + vus = set() + with open(chemin, encoding="utf-8", errors="replace") as f: + for ligne in csv.reader(f): + if len(ligne) < 4 or ligne[0] in vus: + continue + vus.add(ligne[0]) + try: + lat, lon = float(ligne[2]), float(ligne[3]) + if lat == 0 and lon == 0: + lat = lon = None + except ValueError: + lat = lon = None + m = {"fichier": ligne[0], "type": type_, "date": ligne[1], "lat": lat, "lon": lon, + "deplace": ligne[0] in corr} + if m["deplace"]: + m["lat"], m["lon"] = corr[ligne[0]] + res.append(m) + return res diff --git a/rtg/extraction.py b/rtg/extraction.py index 887d3bd..f52fd2a 100644 --- a/rtg/extraction.py +++ b/rtg/extraction.py @@ -13,6 +13,7 @@ import subprocess from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo +from rtg import corrections from rtg.outils import dire, exiger FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude' @@ -56,14 +57,29 @@ def extraire_photos(voyage, cfg, analyse): if pos: lignes.append(f"{nom},{date},{pos[0]},{pos[1]},") deduites += 1 + # Photos datées restées sans position : lignes sans coordonnées, ignorées + # par les pages mais listées par l'interface pour être placées à la main. + vus = {l.split(",")[0] for l in lignes} + sans_position = [l.split(",", 1) for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier]) + if l.split(",")[0] not in vus] + lignes += [f"{nom},{date},,," for nom, date in sans_position] with open(csv, "w", encoding="utf-8") as f: f.write("".join(l + "\n" for l in lignes)) - msg = f"📸 Photos : {len(lignes)} ligne(s)" - if len(lignes) > len(p1) + deduites: - msg += f" (dont {len(lignes) - len(p1) - deduites} sans altitude)" + localisees = len(lignes) - len(sans_position) + msg = f"📸 Photos : {localisees} localisée(s)" + if localisees > len(p1) + deduites: + msg += f" (dont {localisees - len(p1) - deduites} sans altitude)" if deduites: msg += f", {deduites} position(s) déduite(s) de la trace" + if sans_position: + placees = corrections.positions(cfg) + a_la_main = sum(1 for nom, _ in sans_position if nom.strip('"') in placees) + reste = len(sans_position) - a_la_main + if a_la_main: + msg += f", {a_la_main} placée(s) à la main" + if reste: + msg += f", {reste} sans position (à placer dans l'interface)" dire(msg) @@ -141,6 +157,8 @@ def _premier_point(video): date = dt or cd if premier and date: return date, premier[0], premier[1] + if date: + return date, None, None # datée mais sans GPS : à placer à la main return None @@ -169,15 +187,19 @@ def extraire_videos(voyage, analyse): reprises += 1 continue r = _premier_point(v) - if r: + if r and r[1] is not None: lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,') etat = "✓" else: sans += 1 etat = "— pas de GPS" + if r: + lignes.append(f'"{nom}",{r[0]},,,,') dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}") if reprises: dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export") with open(csv, "w", encoding="utf-8") as f: f.write("".join(l + "\n" for l in lignes)) - dire(f"🎬 Vidéos : {len(lignes)} rush(s) localisé(s)" + (f", {sans} sans GPS" if sans else "")) + sans_pos = sum(1 for l in lignes if l.split(",")[2:4] == ["", ""]) + dire(f"🎬 Vidéos : {len(lignes) - sans_pos} rush(s) localisé(s)" + + (f", {sans_pos} sans GPS (à placer dans l'interface)" if sans_pos else "")) diff --git a/rtg/interface/modeles/base.html b/rtg/interface/modeles/base.html index d0bb26e..63a53e8 100644 --- a/rtg/interface/modeles/base.html +++ b/rtg/interface/modeles/base.html @@ -18,6 +18,7 @@ Voir le site ↗
+
{% block contenu %}{% endblock %} diff --git a/rtg/interface/modeles/connexion.html b/rtg/interface/modeles/connexion.html new file mode 100644 index 0000000..81661c6 --- /dev/null +++ b/rtg/interface/modeles/connexion.html @@ -0,0 +1,37 @@ + + + + + +Connexion · RTG + + + + +
+ + {% if connecte %} +

Vous êtes connecté.

+

Ouvrir l'interface

+ {% else %} +

Accès à l'interface

+ {% if not distant %} +

Ouvrez l'adresse complète affichée dans le Terminal où « rtg interface » a été lancé : + elle contient la clé d'accès de cette session{% if mdp %}, ou entrez le mot de passe{% endif %}.

+ {% endif %} + {% if mdp %} +
+ + +
{% if attente %}
Trop d'essais : réessayez dans {{ attente }} s.
{% endif %}
+
+ {% elif not distant %} +

Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) : + rtg mot-de-passe.

+ {% endif %} + {% endif %} +
+ + diff --git a/rtg/interface/modeles/voyage.html b/rtg/interface/modeles/voyage.html index 214765b..eccee84 100644 --- a/rtg/interface/modeles/voyage.html +++ b/rtg/interface/modeles/voyage.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block titre %}{{ v.titre }}{% endblock %} {% block tete %}{% endblock %} +{% block fin %}{% endblock %} {% block contenu %}
@@ -15,6 +16,7 @@
+
+
+ +

Chargement de la carte…

+
+
+

Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque. RTG ne les modifie jamais : les traces sont recopiées par journée dans routes/, les photos dans photos/ (HEIC converties en JPEG), diff --git a/rtg/interface/securite.py b/rtg/interface/securite.py new file mode 100644 index 0000000..d53fc12 --- /dev/null +++ b/rtg/interface/securite.py @@ -0,0 +1,93 @@ +"""Contrôle d'accès à l'interface. + +Deux façons d'entrer : +- la clé d'accès (jeton) tirée au hasard à chaque lancement et ajoutée à + l'adresse que RTG ouvre dans le navigateur (et affiche dans le Terminal) : + seul celui qui voit ce Terminal peut entrer ; +- le mot de passe défini par `rtg mot-de-passe`, indispensable pour un accès + depuis une autre machine (`rtg interface --distant`). + +Une fois entré, le navigateur garde un cookie de session (HttpOnly, +SameSite=Strict), valable 12 heures après la dernière requête et perdu à +l'arrêt de l'interface. Après 5 mots de passe faux, une adresse IP est +bloquée une minute, puis deux, quatre… jusqu'à une heure. +""" +import hmac +import secrets +import threading +import time + +from rtg import acces + +DUREE_SESSION = 12 * 3600 +ESSAIS_LIBRES = 5 + + +class Gardien: + def __init__(self, port, distant=False, https=False): + self.jeton = secrets.token_urlsafe(24) + self.cookie = f"rtg_{port}" + self.distant, self.https = distant, https + self._sessions = {} + self._echecs = {} # ip → (nombre d'échecs, heure du dernier) + self._verrou = threading.Lock() + + # --- sessions + def ouvrir_session(self): + sid = secrets.token_urlsafe(32) + with self._verrou: + self._sessions[sid] = time.time() + DUREE_SESSION + return sid + + def session_valide(self, sid): + if not sid: + return False + maintenant = time.time() + with self._verrou: + fin = self._sessions.get(sid) + if not fin or fin < maintenant: + self._sessions.pop(sid, None) + return False + self._sessions[sid] = maintenant + DUREE_SESSION + return True + + def fermer_session(self, sid): + with self._verrou: + self._sessions.pop(sid, None) + + def entete_cookie(self, sid, effacer=False): + valeur = f"{self.cookie}={'' if effacer else sid}; Path=/; HttpOnly; SameSite=Strict" + if effacer: + valeur += "; Max-Age=0" + if self.https: + valeur += "; Secure" + return valeur + + # --- clé d'accès et mot de passe + def jeton_valide(self, jeton): + return bool(jeton) and hmac.compare_digest(jeton.encode(), self.jeton.encode()) + + def attente(self, ip): + """Secondes à attendre avant un nouvel essai (0 si libre).""" + with self._verrou: + n, dernier = self._echecs.get(ip, (0, 0)) + if n < ESSAIS_LIBRES: + return 0 + blocage = min(60 * 2 ** (n - ESSAIS_LIBRES), 3600) + return max(0, int(dernier + blocage - time.time())) + + def essayer(self, ip, mdp): + """Vérifie le mot de passe. Renvoie (ok, message d'erreur).""" + reste = self.attente(ip) + if reste: + return False, f"Trop d'essais : réessayez dans {reste} s." + if not acces.defini(): + return False, "Aucun mot de passe n'est défini (commande rtg mot-de-passe)." + if acces.verifier(mdp): + with self._verrou: + self._echecs.pop(ip, None) + return True, "" + with self._verrou: + n, _ = self._echecs.get(ip, (0, 0)) + self._echecs[ip] = (n + 1, time.time()) + return False, "Mot de passe incorrect." diff --git a/rtg/interface/serveur.py b/rtg/interface/serveur.py index f0e152e..ec0f18b 100644 --- a/rtg/interface/serveur.py +++ b/rtg/interface/serveur.py @@ -1,16 +1,23 @@ -"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur. +"""Interface de RTG : un petit serveur web, ouvert dans le navigateur. Rien n'est installé en plus : le serveur vient de Python, les pages sont des modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont -fournis dans static/. Le serveur n'écoute que sur 127.0.0.1. +fournis dans static/. Par défaut le serveur n'écoute que sur 127.0.0.1 ; +`--distant` l'ouvre au réseau, avec mot de passe obligatoire. -Sécurité : une page web quelconque ouverte dans le navigateur pourrait -envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose -doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter -sans l'accord du serveur), et l'en-tête Host doit être le nôtre. +Sécurité (voir aussi securite.py) : +- il faut la clé d'accès du lancement ou le mot de passe pour entrer ; +- une page web quelconque ouverte dans le navigateur pourrait envoyer des + requêtes à ce serveur : toute requête qui modifie quelque chose doit donc + porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter sans + l'accord du serveur) ; +- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ; +- les pages de l'interface interdisent leur affichage dans un cadre et + n'exécutent que les scripts servis par RTG (Content-Security-Policy). """ import io import json +import ssl import mimetypes import os import platform @@ -26,10 +33,11 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit from jinja2 import Environment, FileSystemLoader, select_autoescape -from rtg import __version__, accueil, site, sources, video +from rtg import __version__, acces, accueil, corrections, site, sources, video from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video, reglages_site, reglages_voyage) from rtg.interface import taches +from rtg.interface.securite import Gardien from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible ICI = os.path.dirname(os.path.abspath(__file__)) @@ -108,6 +116,9 @@ class Application: ("POST", r"/voyage/date", self.voyage_date), ("POST", r"/voyage/dates-suite", self.voyage_dates_suite), ("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles), + ("GET", r"/voyage/carte.json", self.voyage_carte), + ("POST", r"/voyage/position", self.voyage_position), + ("POST", r"/voyage/masque", self.voyage_masque), ("GET", r"/dossiers", self.dossiers), ("GET", r"/reglages", self.reglages), ("POST", r"/reglages", self.reglages_enregistrer), @@ -260,10 +271,12 @@ class Application: except OSError: return (f, None, None) fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin) - autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")] - autres.append(os.path.join(self.racine, SITE_TOML)) - photos = cfg["sources"].get("photos", []) - return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),) + autres = [os.path.join(chemin, n) for n in ("export_photos.csv", "export_videos.csv")] + # Seuls les réglages qui changent l'analyse comptent : déplacer une + # photo sur la carte ne doit pas faire relire toutes les traces. + reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"], + cfg.get("exclusions", {})], sort_keys=True, default=str) + return tuple(etat(f) for f in fichiers + autres) + (reglages,) def analyse(self, chemin): cfg = self.cfg_voyage(chemin) @@ -341,6 +354,92 @@ class Application: infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f)) return page("_infos_utiles.html", i=infos, cfg=cfg) + # --- Carte et corrections + def _media_json(self, chemin, rel, m): + nom = m["fichier"] + if m["type"] == "photo": + dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos" + vignette = f"/site/{quote(rel)}/{dossier}/{quote(nom)}" + else: + base = os.path.splitext(nom)[0] + ap = os.path.join(chemin, "apercus", base + ".jpg") + vignette = f"/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None + d = m["date"][:10].replace(":", "-") + return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette) + + def _masques_json(self, cfg): + return {"masques": cfg.get("corrections", {}).get("masques", []), + "zones": cfg.get("confidentialite", {}).get("zones", [])} + + def voyage_carte(self, r): + rel = r.q("v") + chemin = self.chemin_voyage(rel) + cfg, a, jours, _ = self.analyse(chemin) + cfg = self.cfg_voyage(chemin) # positions et masques à jour + medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)] + return json_rep(dict( + self._masques_json(cfg), + couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)], + jours=self._jours_carte(jours, medias), + medias=medias)) + + @staticmethod + def _jours_carte(jours, medias): + """Jours des traces, plus ceux où seuls des médias existent.""" + res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])} + for j in jours} + for m in medias: + if m["jour"] not in res: + try: + d = date.fromisoformat(m["jour"]) + except ValueError: + continue + res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"} + return [res[k] for k in sorted(res)] + + def voyage_position(self, r): + rel = r.f("v") + chemin = self.chemin_voyage(rel) + nom = r.f("fichier") + if not nom: + raise Erreur(400, "Média non précisé") + if r.f("lat") and r.f("lon"): + try: + lat, lon = float(r.f("lat")), float(r.f("lon")) + except ValueError: + raise Erreur(400, "Position invalide") + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise Erreur(400, "Position invalide") + self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}}) + else: + self.toml_voyage(chemin, {"positions": {nom: None}}) + cfg = self.cfg_voyage(chemin) + m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None) + return json_rep({"media": self._media_json(chemin, rel, m) if m else None}) + + def voyage_masque(self, r): + chemin = self.chemin_voyage(r.f("v")) + portee = "site" if r.f("portee") == "site" else "voyage" + cfg = self.cfg_voyage(chemin) + liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"]) + if r.f("action") == "ajouter": + try: + lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon"))) + except ValueError: + raise Erreur(400, "Zone invalide") + liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))]) + elif r.f("action") == "supprimer": + try: + del liste[int(r.f("index"))] + except (ValueError, IndexError): + raise Erreur(400, "Zone introuvable") + if portee == "site": + modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}}, + entete="Réglages du site RoadTripsGenerator (rtg).") + else: + self.toml_voyage(chemin, {"corrections": {"masques": liste}}) + return json_rep(self._masques_json(self.cfg_voyage(chemin))) + # --- Navigateur de dossiers def dossiers(self, r): chemin = r.q("chemin") or os.path.expanduser("~") @@ -511,11 +610,21 @@ class Requete: return _valeur(self.form, k, defaut) -def creer_gestionnaire(app, port): +# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds +# de carte, aucun affichage dans un cadre. +CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") +PUBLIQUES = {"/connexion"} + + +def creer_gestionnaire(app, port, gardien=None): + gardien = gardien or Gardien(port) hotes = {f"127.0.0.1:{port}", f"localhost:{port}"} class Gestionnaire(BaseHTTPRequestHandler): server_version = f"rtg/{__version__}" + sys_version = "" def log_message(self, fmt, *args): pass # le terminal sert au journal des traitements @@ -529,23 +638,59 @@ def creer_gestionnaire(app, port): def do_POST(self): self.traiter("POST") + def session(self): + for morceau in (self.headers.get("Cookie") or "").split(";"): + nom, _, valeur = morceau.strip().partition("=") + if nom == gardien.cookie: + return valeur + return "" + + def rediriger(self, cible, cookie=None): + self.send_response(303) + self.send_header("Location", cible) + if cookie: + self.send_header("Set-Cookie", cookie) + self.send_header("Content-Length", "0") + self.end_headers() + def traiter(self, methode, tete=False): try: - if self.headers.get("Host") not in hotes: + if not gardien.distant and self.headers.get("Host") not in hotes: raise Erreur(403, "Hôte refusé") url = urlsplit(self.path) chemin = unquote(url.path) + query = parse_qs(url.query, keep_blank_values=True) if methode == "GET" and chemin.startswith("/static/"): return self.fichier(STATIC, chemin[len("/static/"):], tete) - if methode == "GET" and chemin.startswith("/site/"): - return self.fichier(app.racine, chemin[len("/site/"):], tete) + # Adresse ouverte au lancement : la clé ouvre une session, puis + # on la retire de l'adresse (historique, copier-coller). + if "jeton" in query: + if gardien.jeton_valide(_valeur(query, "jeton", "")): + sid = gardien.ouvrir_session() + return self.rediriger(url.path or "/", gardien.entete_cookie(sid)) + return self.rediriger("/connexion") + connecte = gardien.session_valide(self.session()) form = {} if methode == "POST": if self.headers.get("HX-Request") != "true": raise Erreur(403, "Requête refusée") n = int(self.headers.get("Content-Length") or 0) + if n > 2_000_000: + raise Erreur(413, "Requête trop grosse") form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True) - req = Requete(parse_qs(url.query, keep_blank_values=True), form) + if chemin in PUBLIQUES: + return self.envoyer(self.connexion(methode, form, connecte), tete) + if not connecte: + if self.headers.get("HX-Request") == "true": + return self.envoyer(Reponse("", entetes={"HX-Redirect": "/connexion"}), tete) + return self.rediriger("/connexion") + if chemin == "/deconnexion" and methode == "POST": + gardien.fermer_session(self.session()) + return self.envoyer(Reponse("", entetes={ + "HX-Redirect": "/connexion", "Set-Cookie": gardien.entete_cookie("", effacer=True)}), tete) + if methode == "GET" and chemin.startswith("/site/"): + return self.fichier(app.racine, chemin[len("/site/"):], tete) + req = Requete(query, form) for m, motif, fn in app.routes: if m == methode and motif.match(chemin): rep = fn(req) @@ -568,11 +713,26 @@ def creer_gestionnaire(app, port): rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"} self.envoyer(rep, tete) + def connexion(self, methode, form, connecte): + ip = self.client_address[0] + if methode == "POST": + ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", "")) + if ok: + sid = gardien.ouvrir_session() + return Reponse("", entetes={"HX-Redirect": "/", "Set-Cookie": gardien.entete_cookie(sid)}) + return Reponse(f'

{_echapper(message)}
') + return page("connexion.html", mdp=acces.defini(), connecte=connecte, + distant=gardien.distant, attente=gardien.attente(ip)) + def envoyer(self, rep, tete=False): self.send_response(rep.code) self.send_header("Content-Type", rep.type) self.send_header("Content-Length", str(len(rep.corps))) self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", CSP) + self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") for k, v in rep.entetes.items(): self.send_header(k, v) self.end_headers() @@ -642,21 +802,41 @@ def _echapper(s): return str(s).replace("&", "&").replace("<", "<").replace(">", ">") -def lancer(racine, port=8765, ouvrir=True): +def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None): + """Lance l'interface. distant=True : écoute sur toutes les interfaces + réseau, mot de passe obligatoire ; certificat et cle : HTTPS.""" + if distant and not acces.defini(): + raise SystemExit("❌ L'accès distant demande un mot de passe : lancez d'abord « rtg mot-de-passe ».") + contexte_tls = None + if certificat: + contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2 + contexte_tls.load_cert_chain(certificat, cle) app = Application(racine) serveur = None for p in range(port, port + 20): try: - serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p)) + gardien = Gardien(p, distant=distant, https=bool(contexte_tls)) + serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p), + creer_gestionnaire(app, p, gardien)) port = p break except OSError: continue if serveur is None: raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.") + if contexte_tls: + serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True) serveur.daemon_threads = True - url = f"http://127.0.0.1:{port}/" + schema = "https" if contexte_tls else "http" + url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}" print(f"🌐 Interface RTG : {url}") + print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)") + if distant: + print(f" Accès depuis le réseau : {schema}://:{port}/ (mot de passe)") + if not contexte_tls: + print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre") + print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.") print(f" Site : {app.racine}") print(" (Ctrl+C pour arrêter)") if ouvrir: diff --git a/rtg/interface/static/editeur.js b/rtg/interface/static/editeur.js new file mode 100644 index 0000000..94c7b2b --- /dev/null +++ b/rtg/interface/static/editeur.js @@ -0,0 +1,282 @@ +// Onglet « Carte et corrections » : déplacer ou placer les photos et vidéos, +// masquer des zones de trace. Chaque correction est enregistrée aussitôt dans +// voyage.toml (ou site.toml pour une zone privée) ; les pages du site la +// prennent en compte à la génération suivante. +(function () { + "use strict"; + const racine = document.getElementById("editeur"); + if (!racine || !window.L) return; + const voyage = racine.dataset.voyage; + const $ = id => document.getElementById(id); + let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones; + let brouillon = null, modeZone = false, aPlacer = null, cadree = false; + + // --- Échanges avec le serveur ------------------------------------------- + function message(html) { + const m = $("messages"); + m.innerHTML = html; + clearTimeout(window._rtgMessage); + window._rtgMessage = setTimeout(() => { m.innerHTML = ""; }, 6000); + } + + function lireReponse(r) { + const redirection = r.headers.get("HX-Redirect"); + if (redirection) { location.href = redirection; throw new Error("session"); } + if (!(r.headers.get("Content-Type") || "").includes("json")) { + return r.text().then(t => { message(t); throw new Error("refus"); }); + } + return r.json(); + } + + function poster(url, params) { + return fetch(url, { + method: "POST", + headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(Object.assign({ v: voyage }, params)) + }).then(lireReponse); + } + + function etat(texte) { + $("ed-etat").textContent = texte; + } + + function charger() { + return fetch("/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } }) + .then(lireReponse) + .then(d => { donnees = d; remplirJours(); dessinerTout(); }); + } + + // --- Carte ---------------------------------------------------------------- + function initialiser() { + $("ed-carte").innerHTML = ""; + carte = L.map("ed-carte"); + const plan = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", + { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(carte); + const satellite = L.tileLayer( + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + { maxZoom: 19, attribution: "Esri World Imagery" }); + L.control.layers({ "Plan": plan, "Satellite": satellite }).addTo(carte); + carte.setView([46.5, 2.5], 5); + coucheTraces = L.featureGroup().addTo(carte); + coucheZones = L.featureGroup().addTo(carte); + coucheMedias = L.featureGroup().addTo(carte); + carte.on("click", clicCarte); + $("ed-jour").addEventListener("change", () => { cadree = false; dessinerTout(); }); + $("ed-photos").addEventListener("change", dessinerMedias); + $("ed-videos").addEventListener("change", dessinerMedias); + $("ed-nouvelle-zone").addEventListener("click", commencerZone); + $("ed-zone-annuler").addEventListener("click", finirZone); + $("ed-zone-ok").addEventListener("click", enregistrerZone); + $("ed-rayon").addEventListener("input", () => { + $("ed-rayon-texte").textContent = $("ed-rayon").value + " m"; + if (brouillon) brouillon.setRadius(+$("ed-rayon").value); + }); + document.addEventListener("keydown", e => { + if (e.key === "Escape") { aPlacer = null; finirZone(); mode(""); } + }); + document.body.addEventListener("tache-finie", () => charger()); + charger(); + } + + function remplirJours() { + const choix = $("ed-jour"), actuel = choix.value; + choix.length = 1; + donnees.jours.forEach(j => choix.add(new Option(j.nom, j.jour))); + choix.value = actuel; + } + + function jourChoisi() { return $("ed-jour").value; } + + function dessinerTout() { + dessinerTraces(); + dessinerZones(); + dessinerMedias(); + if (!cadree) { + const b = coucheTraces.getLayers().length ? coucheTraces.getBounds() + : (coucheMedias.getLayers().length ? coucheMedias.getBounds() : null); + if (b && b.isValid()) carte.fitBounds(b, { padding: [30, 30] }); + cadree = true; + } + } + + function dessinerTraces() { + coucheTraces.clearLayers(); + const jour = jourChoisi(); + donnees.couches.forEach(c => { + if (jour && c.jour !== jour) return; + c.segments.forEach(s => L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, interactive: false }) + .addTo(coucheTraces)); + }); + } + + function icone(m) { + return L.divIcon({ className: "", iconSize: [16, 16], iconAnchor: [8, 8], + html: '' }); + } + + function popup(m) { + const d = document.createElement("div"); + d.className = "ed-popup"; + if (m.vignette) { + const img = document.createElement("img"); + img.src = m.vignette; img.alt = ""; img.loading = "lazy"; + d.append(img); + } + const t = document.createElement("div"); + t.textContent = m.fichier + " · " + m.jour + " " + m.heure; + d.append(t); + if (m.deplace) { + const b = document.createElement("button"); + b.className = "petit"; + b.textContent = "Revenir à la position d'origine"; + b.addEventListener("click", () => { carte.closePopup(); enregistrerPosition(m.fichier, "", ""); }); + d.append(b); + } + return d; + } + + function dessinerMedias() { + coucheMedias.clearLayers(); + const jour = jourChoisi(), photos = $("ed-photos").checked, videos = $("ed-videos").checked; + const sans = []; + donnees.medias.forEach(m => { + if (jour && m.jour !== jour) return; + if ((m.type === "photo" && !photos) || (m.type === "video" && !videos)) return; + if (m.lat === null) { sans.push(m); return; } + L.marker([m.lat, m.lon], { icon: icone(m), draggable: true, title: m.fichier }) + .bindPopup(() => popup(m), { minWidth: 200 }) + .on("dragend", e => { + const p = e.target.getLatLng(); + enregistrerPosition(m.fichier, p.lat.toFixed(6), p.lng.toFixed(6)); + }) + .addTo(coucheMedias); + }); + const liste = $("ed-sans"); + liste.innerHTML = ""; + $("ed-nb-sans").textContent = sans.length; + sans.forEach(m => { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = "#carte"; + a.textContent = (m.type === "photo" ? "📷 " : "🎬 ") + m.fichier; + a.title = m.jour + " " + m.heure; + a.addEventListener("click", e => { + e.preventDefault(); + finirZone(); + aPlacer = m.fichier; + mode("Cliquez sur la carte pour placer " + m.fichier + " (Échap pour annuler)"); + }); + const quand = document.createElement("span"); + quand.className = "discret"; + quand.textContent = " " + m.jour + " " + m.heure; + li.append(a, quand); + liste.append(li); + }); + if (!sans.length) liste.innerHTML = '
  • Aucun
  • '; + } + + function enregistrerPosition(fichier, lat, lon) { + poster("/voyage/position", { fichier: fichier, lat: lat, lon: lon }).then(r => { + if (r.media) { + const i = donnees.medias.findIndex(m => m.fichier === fichier); + if (i >= 0) donnees.medias[i] = r.media; + } + dessinerMedias(); + etat("✓ " + fichier + (lat ? " placé" : " remis à sa position d'origine") + + ". Régénérez le voyage pour mettre le site à jour."); + }).catch(() => dessinerMedias()); + } + + // --- Zones masquées ------------------------------------------------------- + function dessinerZones() { + coucheZones.clearLayers(); + const liste = $("ed-zones"); + liste.innerHTML = ""; + const ajouter = (z, i, portee) => { + const site = portee === "site"; + const cercle = L.circle([z[0], z[1]], { + radius: z[2], color: site ? "#7c3aed" : "#dc2626", weight: 2, fillOpacity: 0.15, + dashArray: site ? "6 6" : null + }).addTo(coucheZones); + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = "#carte"; + a.textContent = (site ? "Zone privée" : "Zone") + " · " + z[2] + " m"; + a.addEventListener("click", e => { e.preventDefault(); carte.fitBounds(cercle.getBounds(), { maxZoom: 16 }); }); + const b = document.createElement("button"); + b.className = "petit"; + b.textContent = "Supprimer"; + b.addEventListener("click", () => { + poster("/voyage/masque", { action: "supprimer", portee: portee, index: i }).then(r => { + donnees.masques = r.masques; donnees.zones = r.zones; dessinerZones(); + etat("✓ Zone supprimée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour."); + }); + }); + li.append(a, " ", b); + if (site) li.title = "Retirée de tous les voyages (site.toml)"; + liste.append(li); + }; + donnees.masques.forEach((z, i) => ajouter(z, i, "voyage")); + donnees.zones.forEach((z, i) => ajouter(z, i, "site")); + } + + function commencerZone() { + aPlacer = null; + modeZone = true; + mode("Cliquez au centre de la zone à masquer ; un nouveau clic la déplace."); + } + + function finirZone() { + modeZone = false; + if (brouillon) { brouillon.remove(); brouillon = null; } + $("ed-brouillon").hidden = true; + if (!aPlacer) mode(""); + } + + function enregistrerZone() { + if (!brouillon) return; + const c = brouillon.getLatLng(), site = $("ed-portee-site").checked; + poster("/voyage/masque", { + action: "ajouter", portee: site ? "site" : "voyage", + lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), rayon: $("ed-rayon").value + }).then(r => { + donnees.masques = r.masques; donnees.zones = r.zones; + finirZone(); + dessinerZones(); + etat("✓ Zone enregistrée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour."); + }); + } + + function clicCarte(e) { + if (aPlacer) { + const nom = aPlacer; + aPlacer = null; + mode(""); + enregistrerPosition(nom, e.latlng.lat.toFixed(6), e.latlng.lng.toFixed(6)); + } else if (modeZone) { + if (!brouillon) { + brouillon = L.circle(e.latlng, { radius: +$("ed-rayon").value, color: "#dc2626", weight: 2, + dashArray: "4 4", fillOpacity: 0.2 }).addTo(carte); + } else { + brouillon.setLatLng(e.latlng); + } + $("ed-brouillon").hidden = false; + mode("Ajustez le rayon puis enregistrez ; un clic sur la carte déplace la zone."); + } + } + + function mode(texte) { + const m = $("ed-mode"); + m.textContent = texte; + m.hidden = !texte; + $("ed-carte").classList.toggle("en-mode", !!texte); + } + + // L'onglet peut être caché au chargement : on prépare la carte à sa première ouverture. + document.addEventListener("rtg:onglet", e => { + if (e.detail !== "carte") return; + if (!carte) initialiser(); + else carte.invalidateSize(); + }); + if (location.hash === "#carte") setTimeout(() => { if (!carte) initialiser(); }, 0); +})(); diff --git a/rtg/interface/static/rtg.css b/rtg/interface/static/rtg.css index 26946a6..9c2d268 100644 --- a/rtg/interface/static/rtg.css +++ b/rtg/interface/static/rtg.css @@ -145,3 +145,43 @@ progress.global { height: 4px; opacity: .7; } th, td { padding: 6px 8px; } .carte { height: 300px; } } + +/* Connexion */ +.page-connexion main.connexion { max-width: 420px; margin: 12vh auto; background: var(--carte); border: 1px solid var(--bord); + border-radius: 14px; padding: 24px 28px; } +.connexion .logo { margin: 0 0 8px; } +.connexion label { display: block; margin: 14px 0 10px; font-weight: 500; } +.connexion input[type=password] { display: block; width: 100%; margin-top: 4px; } +.discret-bouton { color: var(--discret); } + +/* Carte et corrections */ +.editeur { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; } +.editeur-panneau { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 14px 12px; + max-height: 74vh; overflow-y: auto; } +.editeur-panneau h4 { margin: 18px 0 4px; font-size: 14px; } +.editeur-panneau label { display: block; margin: 8px 0; } +.editeur-panneau select { width: 100%; margin-top: 4px; } +.editeur-panneau input[type=range] { width: 100%; } +.ed-cases { display: flex; gap: 16px; } +.editeur-carte { height: 74vh; border-radius: 10px; border: 1px solid var(--bord); } +.editeur-carte.en-mode { cursor: crosshair; outline: 3px solid var(--accent); } +.editeur-carte.en-mode .leaflet-container, .editeur-carte.en-mode.leaflet-container { cursor: crosshair; } +.ed-mode { background: var(--attention-fond); color: var(--attention); padding: 6px 10px; border-radius: 8px; font-size: 13px; margin: 8px 0; } +.ed-brouillon { background: var(--neutre-fond); border-radius: 8px; padding: 4px 10px; margin: 8px 0; } +.ed-liste { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; } +.ed-liste li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.ed-liste a { text-decoration: none; overflow-wrap: anywhere; } +.mk { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0,0,0,.35); vertical-align: middle; } +.mk.photo { background: #ef4444; } +.mk.video { background: #2563eb; } +.mk.deplace { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; } +.leaflet-marker-draggable .mk { cursor: grab; } +.ed-popup { max-width: 240px; font-size: 12px; } +.ed-popup img { display: block; max-width: 240px; max-height: 180px; border-radius: 6px; margin-bottom: 4px; } +.ed-popup button { margin-top: 6px; } +@media (max-width: 800px) { + .editeur { grid-template-columns: 1fr; } + .editeur-panneau { max-height: none; } + .editeur-carte { height: 60vh; } +} diff --git a/rtg/interface/static/rtg.js b/rtg/interface/static/rtg.js index 7b25c18..489337d 100644 --- a/rtg/interface/static/rtg.js +++ b/rtg/interface/static/rtg.js @@ -18,6 +18,7 @@ s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1))); }); document.querySelectorAll(".onglet.actif .carte").forEach(cadrer); + document.dispatchEvent(new CustomEvent("rtg:onglet", { detail: trouve ? nom : "" })); } window.addEventListener("hashchange", afficherOnglet); document.addEventListener("DOMContentLoaded", afficherOnglet); diff --git a/rtg/pages.py b/rtg/pages.py index 8289284..932fe8f 100644 --- a/rtg/pages.py +++ b/rtg/pages.py @@ -35,6 +35,8 @@ from folium import plugins from folium.plugins import MarkerCluster from jinja2 import Environment, FileSystemLoader +from rtg import corrections + DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") # Fixés par generer() : racine du site et adresse publique. CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"} @@ -101,13 +103,18 @@ def bornes(points, marge=0.02, robuste=True): len(points) - len(gardes)) -def lire_csv(chemin): - """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.""" +def lire_csv(chemin, positions=None): + """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour. + `positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent.""" vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour']) # Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire. if not os.path.exists(chemin) or os.path.getsize(chemin) == 0: return vide df = pd.read_csv(chemin, names=COLONNES, header=None) + for nom, (lat, lon) in (positions or {}).items(): + lignes = df['Fichier'].astype(str).str.strip() == nom + df.loc[lignes, 'Latitude'] = lat + df.loc[lignes, 'Longitude'] = lon df = df[df['Latitude'].notnull() & df['Longitude'].notnull()] df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy() if df.empty: @@ -312,12 +319,16 @@ def exporter_trace_globale(base_dir, pistes): class Voyage: """Données communes à toutes les pages d'un voyage.""" - def __init__(self, base_dir): + def __init__(self, base_dir, cfg=None): self.base_dir = base_dir self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage" self.routes_dir = os.path.join(base_dir, "routes") - self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv")) - self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv")) + cfg = cfg or {} + self.zones = corrections.masques(cfg) + pos = corrections.positions(cfg) + self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos) + self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos) + self.points_masques = 0 jours_gpx = set() for f in fichiers_gpx(self.routes_dir): m = re.search(r'(\d{4}-\d{2}-\d{2})', f) @@ -343,6 +354,7 @@ class Voyage: continue self.gpx_du_jour[jour] = gpx_file for track in gpx.tracks: + self.points_masques += corrections.filtrer_piste(track, self.zones) pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points] if pts: if not track.name: @@ -396,19 +408,22 @@ def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telecharg return m_day, media_coords -def generer(base_dir, racine=None, site_url=None): - """Pages d'un voyage. `racine` : racine du site (bouton « Retour »).""" +def generer(base_dir, racine=None, site_url=None, cfg=None): + """Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ; + `cfg` : réglages du voyage (corrections faites dans l'interface).""" CONTEXTE["racine"] = racine if site_url: CONTEXTE["site_url"] = site_url.rstrip('/') + '/' base_dir = os.path.abspath(base_dir) html_dir = os.path.join(base_dir, "html") os.makedirs(html_dir, exist_ok=True) - v = Voyage(base_dir) + v = Voyage(base_dir, cfg) if not v.jours: print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).") return False v.lire_traces() + if v.points_masques: + print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées") version = installer_assets(base_dir) journal = Journal(base_dir, v.jours) diff --git a/rtg/site.py b/rtg/site.py index cf8983a..34c3190 100644 --- a/rtg/site.py +++ b/rtg/site.py @@ -87,7 +87,7 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suiv etape("Photos") photos.preparer(voyage, cfg, force=force) etape("Pages") - ok = pages.generer(voyage, racine, cfg["adresse_publique"]) + ok = pages.generer(voyage, racine, cfg["adresse_publique"], cfg) dire() return ok diff --git a/tests/test_interface.py b/tests/test_interface.py index 75aacf9..1c6fd64 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -10,7 +10,9 @@ from http.server import ThreadingHTTPServer import pytest from rtg import config, sources +from rtg import acces, corrections from rtg.interface import serveur +from rtg.interface.securite import Gardien from test_chaine import _photo, pytestmark # noqa: F401 (mêmes prérequis) @@ -64,19 +66,24 @@ def client(voyage_sans_heures): app = serveur.Application(str(racine)) srv = ThreadingHTTPServer(("127.0.0.1", 0), None) port = srv.server_address[1] - srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port) + gardien = Gardien(port) + srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien) threading.Thread(target=srv.serve_forever, daemon=True).start() + sid = gardien.ouvrir_session() - def appel(chemin, donnees=None, htmx=True): + def appel(chemin, donnees=None, htmx=True, session=True): req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}", data=urllib.parse.urlencode(donnees).encode() if donnees else None) if htmx: req.add_header("HX-Request", "true") + if session: + req.add_header("Cookie", f"{gardien.cookie}={sid}") try: with urllib.request.urlopen(req) as r: return r.status, r.read().decode() except urllib.error.HTTPError as e: return e.code, e.read().decode() + appel.port, appel.gardien = port, gardien yield appel, racine, voyage srv.shutdown() @@ -98,3 +105,74 @@ def test_serveur(client): # Voyage hors du site : refusé code, html = appel("/voyage/analyse?v=../../etc") assert "introuvable" in html.lower() + + +class _SansRedirection(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *a, **k): + return None + + +def test_acces(client, tmp_path, monkeypatch): + appel, racine, voyage = client + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("APPDATA", str(tmp_path / "config")) + base = f"http://127.0.0.1:{appel.port}" + ouvreur = urllib.request.build_opener(_SansRedirection) + + def brut(chemin): + try: + return ouvreur.open(base + chemin) + except urllib.error.HTTPError as e: + return e + # Sans session : renvoyé vers la connexion, y compris pour les fichiers du site + for chemin in ("/", "/site/site.toml", "/voyage?v=Ecosse-2027"): + r = brut(chemin) + assert r.status == 303 and r.headers["Location"] == "/connexion", chemin + assert appel("/", session=False)[0] == 200 # htmx : HX-Redirect, corps vide + # La clé d'accès ouvre une session (cookie HttpOnly), une fausse clé non + r = brut("/?jeton=" + appel.gardien.jeton) + assert r.status == 303 and "HttpOnly" in r.headers["Set-Cookie"] and "SameSite=Strict" in r.headers["Set-Cookie"] + assert brut("/?jeton=faux").headers["Location"] == "/connexion" + # Mot de passe : faux, juste, puis blocage après 5 échecs + acces.definir("un mot de passe solide") + code, html = appel("/connexion", {"mot_de_passe": "non"}, session=False) + assert "incorrect" in html + ok, _ = appel.gardien.essayer("10.0.0.1", "un mot de passe solide") + assert ok + for _ in range(5): + appel.gardien.essayer("10.0.0.2", "faux") + ok, message = appel.gardien.essayer("10.0.0.2", "un mot de passe solide") + assert not ok and "Trop d'essais" in message + # En-têtes de sécurité + r = urllib.request.urlopen(urllib.request.Request(base + "/connexion")) + assert r.headers["X-Frame-Options"] == "DENY" and "frame-ancestors 'none'" in r.headers["Content-Security-Policy"] + + +def test_corrections(client): + appel, racine, voyage = client + # Médias : une photo localisée, une sans position + with open(voyage / "export_photos.csv", "w") as f: + f.write('"P1.jpg",2027:06:11 10:00:00,56.75,2.30,\n"P2.jpg",2027:06:11 11:00:00,,,\n') + code, texte = appel("/voyage/position", {"v": "Ecosse-2027", "fichier": "P2.jpg", "lat": "56.8", "lon": "2.36"}) + assert '"deplace": true' in texte + cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine))) + assert corrections.positions(cfg) == {"P2.jpg": (56.8, 2.36)} + # Zone masquée du voyage, puis zone privée du site + appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "lat": "56.7", "lon": "2.24", "rayon": "3000"}) + appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "portee": "site", + "lat": "48.8", "lon": "2.1", "rayon": "500"}) + cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine))) + assert corrections.masques(cfg) == [(56.7, 2.24, 3000.0), (48.8, 2.1, 500.0)] + # Génération : la photo placée apparaît, le début d'Etape 2 est retiré + config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10", + "Etape 2.gpx": "2027-06-11"}}) + from rtg import pages, sources + cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine))) + sources.ranger(str(voyage), cfg, sources.analyser(str(voyage), cfg)) + v = pages.Voyage(str(voyage), cfg) + assert set(v.df_p["Fichier"]) == {"P1.jpg", "P2.jpg"} + v.lire_traces() + assert v.points_masques > 0 + for pistes in v.traces.values(): + for piste, pts in pistes: + assert all(corrections.distance_m(la, lo, 56.7, 2.24) > 3000 for la, lo in pts)