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

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

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

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

No files matched your search

+16 -2
View File
@@ -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 `<FilesMatch "\.toml$"> Require all denied </FilesMatch>` 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`.
+73
View File
@@ -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)
+27 -1
View File
@@ -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)
+3
View File
@@ -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
+107
View File
@@ -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
+27 -5
View File
@@ -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 ""))
+1
View File
@@ -18,6 +18,7 @@
<a href="/site/" target="_blank">Voir le site ↗</a>
</nav>
<div id="bandeau" hx-get="/tache/bandeau" hx-trigger="load, every 2s"></div>
<button class="petit discret-bouton" hx-post="/deconnexion" title="Fermer la session de ce navigateur">Se déconnecter</button>
</header>
<main>
{% block contenu %}{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion · RTG</title>
<link rel="stylesheet" href="/static/rtg.css">
<script src="/static/htmx.min.js"></script>
</head>
<body class="page-connexion">
<main class="connexion">
<p class="logo">RTG</p>
{% if connecte %}
<p>Vous êtes connecté.</p>
<p><a class="bouton principal" href="/">Ouvrir l'interface</a></p>
{% else %}
<h1>Accès à l'interface</h1>
{% if not distant %}
<p class="aide">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 %}.</p>
{% endif %}
{% if mdp %}
<form hx-post="/connexion" hx-target="#retour-connexion">
<label>Mot de passe
<input type="password" name="mot_de_passe" autocomplete="current-password" required autofocus>
</label>
<button class="principal">Entrer</button>
<div id="retour-connexion">{% if attente %}<div class="erreur">Trop d'essais : réessayez dans {{ attente }} s.</div>{% endif %}</div>
</form>
{% elif not distant %}
<p class="aide">Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) :
<code>rtg mot-de-passe</code>.</p>
{% endif %}
{% endif %}
</main>
</body>
</html>
+37
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block titre %}{{ v.titre }}{% endblock %}
{% block tete %}<link rel="stylesheet" href="/static/leaflet.css"><script src="/static/leaflet.js"></script>{% endblock %}
{% block fin %}<script src="/static/editeur.js"></script>{% endblock %}
{% block contenu %}
<div class="entete-page">
<div>
@@ -15,6 +16,7 @@
<nav class="onglets">
<a href="#journees">Journées</a>
<a href="#carte">Carte et corrections</a>
<a href="#sources">Sources</a>
<a href="#infos">Infos et récit</a>
<a href="#generer">Générer</a>
@@ -26,6 +28,41 @@
</div>
</section>
<section class="onglet" id="onglet-carte">
<div id="editeur" class="editeur" data-voyage="{{ v.chemin }}">
<aside class="editeur-panneau">
<label>Jour
<select id="ed-jour"><option value="">Tous les jours</option></select>
</label>
<div class="ed-cases">
<label class="choix"><input type="checkbox" id="ed-photos" checked> <span class="mk photo"></span> Photos</label>
<label class="choix"><input type="checkbox" id="ed-videos" checked> <span class="mk video"></span> Vidéos</label>
</div>
<p class="aide">Glissez un marqueur pour corriger sa position ; un clic l'affiche.
<span class="mk photo deplace"></span> = position corrigée à la main.</p>
<div id="ed-mode" class="ed-mode" hidden></div>
<h4>Zones masquées</h4>
<p class="aide">Les points de trace situés dans ces zones sont retirés des pages : autour de chez soi, ou sur une erreur du GPS.</p>
<button id="ed-nouvelle-zone">+ Masquer une zone</button>
<div id="ed-brouillon" class="ed-brouillon" hidden>
<label>Rayon : <strong id="ed-rayon-texte">200 m</strong>
<input type="range" id="ed-rayon" min="20" max="3000" step="10" value="200">
</label>
<label class="choix"><input type="checkbox" id="ed-portee-site"> Zone privée : la retirer de tous les voyages</label>
<p><button class="principal" id="ed-zone-ok">Enregistrer la zone</button> <button id="ed-zone-annuler">Annuler</button></p>
</div>
<ul id="ed-zones" class="ed-liste"></ul>
<h4>Médias sans position <span id="ed-nb-sans" class="compte"></span></h4>
<p class="aide">Cliquez sur un nom puis sur la carte pour le placer.</p>
<ul id="ed-sans" class="ed-liste"></ul>
<p id="ed-etat" class="aide"></p>
</aside>
<div id="ed-carte" class="editeur-carte"><p class="chargement">Chargement de la carte…</p></div>
</div>
</section>
<section class="onglet" id="onglet-sources">
<p class="aide">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 <code>routes/</code>, les photos dans <code>photos/</code> (HEIC converties en JPEG),
+93
View File
@@ -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."
+199 -19
View File
@@ -1,16 +1,23 @@
"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
"""Interface de RTG : un petit serveur web, ouvert dans le navigateur.
Rien n'est installé en plus : le serveur vient de Python, les pages sont des
modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
fournis dans static/. Par défaut le serveur n'écoute que sur 127.0.0.1 ;
`--distant` l'ouvre au réseau, avec mot de passe obligatoire.
Sécurité : une page web quelconque ouverte dans le navigateur pourrait
envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
Sécurité (voir aussi securite.py) :
- il faut la clé d'accès du lancement ou le mot de passe pour entrer ;
- une page web quelconque ouverte dans le navigateur pourrait envoyer des
requêtes à ce serveur : toute requête qui modifie quelque chose doit donc
porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter sans
l'accord du serveur) ;
- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ;
- les pages de l'interface interdisent leur affichage dans un cadre et
n'exécutent que les scripts servis par RTG (Content-Security-Policy).
"""
import io
import json
import ssl
import mimetypes
import os
import platform
@@ -26,10 +33,11 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from rtg import __version__, accueil, site, sources, video
from rtg import __version__, acces, accueil, corrections, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.interface import taches
from rtg.interface.securite import Gardien
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
ICI = os.path.dirname(os.path.abspath(__file__))
@@ -108,6 +116,9 @@ class Application:
("POST", r"/voyage/date", self.voyage_date),
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
("GET", r"/voyage/carte.json", self.voyage_carte),
("POST", r"/voyage/position", self.voyage_position),
("POST", r"/voyage/masque", self.voyage_masque),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
@@ -260,10 +271,12 @@ class Application:
except OSError:
return (f, None, None)
fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
autres.append(os.path.join(self.racine, SITE_TOML))
photos = cfg["sources"].get("photos", [])
return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
autres = [os.path.join(chemin, n) for n in ("export_photos.csv", "export_videos.csv")]
# Seuls les réglages qui changent l'analyse comptent : déplacer une
# photo sur la carte ne doit pas faire relire toutes les traces.
reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"],
cfg.get("exclusions", {})], sort_keys=True, default=str)
return tuple(etat(f) for f in fichiers + autres) + (reglages,)
def analyse(self, chemin):
cfg = self.cfg_voyage(chemin)
@@ -341,6 +354,92 @@ class Application:
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
# --- Carte et corrections
def _media_json(self, chemin, rel, m):
nom = m["fichier"]
if m["type"] == "photo":
dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos"
vignette = f"/site/{quote(rel)}/{dossier}/{quote(nom)}"
else:
base = os.path.splitext(nom)[0]
ap = os.path.join(chemin, "apercus", base + ".jpg")
vignette = f"/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None
d = m["date"][:10].replace(":", "-")
return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette)
def _masques_json(self, cfg):
return {"masques": cfg.get("corrections", {}).get("masques", []),
"zones": cfg.get("confidentialite", {}).get("zones", [])}
def voyage_carte(self, r):
rel = r.q("v")
chemin = self.chemin_voyage(rel)
cfg, a, jours, _ = self.analyse(chemin)
cfg = self.cfg_voyage(chemin) # positions et masques à jour
medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)]
return json_rep(dict(
self._masques_json(cfg),
couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)],
jours=self._jours_carte(jours, medias),
medias=medias))
@staticmethod
def _jours_carte(jours, medias):
"""Jours des traces, plus ceux où seuls des médias existent."""
res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])}
for j in jours}
for m in medias:
if m["jour"] not in res:
try:
d = date.fromisoformat(m["jour"])
except ValueError:
continue
res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"}
return [res[k] for k in sorted(res)]
def voyage_position(self, r):
rel = r.f("v")
chemin = self.chemin_voyage(rel)
nom = r.f("fichier")
if not nom:
raise Erreur(400, "Média non précisé")
if r.f("lat") and r.f("lon"):
try:
lat, lon = float(r.f("lat")), float(r.f("lon"))
except ValueError:
raise Erreur(400, "Position invalide")
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise Erreur(400, "Position invalide")
self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}})
else:
self.toml_voyage(chemin, {"positions": {nom: None}})
cfg = self.cfg_voyage(chemin)
m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None)
return json_rep({"media": self._media_json(chemin, rel, m) if m else None})
def voyage_masque(self, r):
chemin = self.chemin_voyage(r.f("v"))
portee = "site" if r.f("portee") == "site" else "voyage"
cfg = self.cfg_voyage(chemin)
liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"])
if r.f("action") == "ajouter":
try:
lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon")))
except ValueError:
raise Erreur(400, "Zone invalide")
liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))])
elif r.f("action") == "supprimer":
try:
del liste[int(r.f("index"))]
except (ValueError, IndexError):
raise Erreur(400, "Zone introuvable")
if portee == "site":
modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}},
entete="Réglages du site RoadTripsGenerator (rtg).")
else:
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
@@ -511,11 +610,21 @@ class Requete:
return _valeur(self.form, k, defaut)
def creer_gestionnaire(app, port):
# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds
# de carte, aucun affichage dans un cadre.
CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
PUBLIQUES = {"/connexion"}
def creer_gestionnaire(app, port, gardien=None):
gardien = gardien or Gardien(port)
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
class Gestionnaire(BaseHTTPRequestHandler):
server_version = f"rtg/{__version__}"
sys_version = ""
def log_message(self, fmt, *args):
pass # le terminal sert au journal des traitements
@@ -529,23 +638,59 @@ def creer_gestionnaire(app, port):
def do_POST(self):
self.traiter("POST")
def session(self):
for morceau in (self.headers.get("Cookie") or "").split(";"):
nom, _, valeur = morceau.strip().partition("=")
if nom == gardien.cookie:
return valeur
return ""
def rediriger(self, cible, cookie=None):
self.send_response(303)
self.send_header("Location", cible)
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", "0")
self.end_headers()
def traiter(self, methode, tete=False):
try:
if self.headers.get("Host") not in hotes:
if not gardien.distant and self.headers.get("Host") not in hotes:
raise Erreur(403, "Hôte refusé")
url = urlsplit(self.path)
chemin = unquote(url.path)
query = parse_qs(url.query, keep_blank_values=True)
if methode == "GET" and chemin.startswith("/static/"):
return self.fichier(STATIC, chemin[len("/static/"):], tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
# Adresse ouverte au lancement : la clé ouvre une session, puis
# on la retire de l'adresse (historique, copier-coller).
if "jeton" in query:
if gardien.jeton_valide(_valeur(query, "jeton", "")):
sid = gardien.ouvrir_session()
return self.rediriger(url.path or "/", gardien.entete_cookie(sid))
return self.rediriger("/connexion")
connecte = gardien.session_valide(self.session())
form = {}
if methode == "POST":
if self.headers.get("HX-Request") != "true":
raise Erreur(403, "Requête refusée")
n = int(self.headers.get("Content-Length") or 0)
if n > 2_000_000:
raise Erreur(413, "Requête trop grosse")
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
req = Requete(parse_qs(url.query, keep_blank_values=True), form)
if chemin in PUBLIQUES:
return self.envoyer(self.connexion(methode, form, connecte), tete)
if not connecte:
if self.headers.get("HX-Request") == "true":
return self.envoyer(Reponse("", entetes={"HX-Redirect": "/connexion"}), tete)
return self.rediriger("/connexion")
if chemin == "/deconnexion" and methode == "POST":
gardien.fermer_session(self.session())
return self.envoyer(Reponse("", entetes={
"HX-Redirect": "/connexion", "Set-Cookie": gardien.entete_cookie("", effacer=True)}), tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
req = Requete(query, form)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
@@ -568,11 +713,26 @@ def creer_gestionnaire(app, port):
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
self.envoyer(rep, tete)
def connexion(self, methode, form, connecte):
ip = self.client_address[0]
if methode == "POST":
ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", ""))
if ok:
sid = gardien.ouvrir_session()
return Reponse("", entetes={"HX-Redirect": "/", "Set-Cookie": gardien.entete_cookie(sid)})
return Reponse(f'<div class="erreur">{_echapper(message)}</div>')
return page("connexion.html", mdp=acces.defini(), connecte=connecte,
distant=gardien.distant, attente=gardien.attente(ip))
def envoyer(self, rep, tete=False):
self.send_response(rep.code)
self.send_header("Content-Type", rep.type)
self.send_header("Content-Length", str(len(rep.corps)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", CSP)
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
for k, v in rep.entetes.items():
self.send_header(k, v)
self.end_headers()
@@ -642,21 +802,41 @@ def _echapper(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def lancer(racine, port=8765, ouvrir=True):
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None):
"""Lance l'interface. distant=True : écoute sur toutes les interfaces
réseau, mot de passe obligatoire ; certificat et cle : HTTPS."""
if distant and not acces.defini():
raise SystemExit("❌ L'accès distant demande un mot de passe : lancez d'abord « rtg mot-de-passe ».")
contexte_tls = None
if certificat:
contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2
contexte_tls.load_cert_chain(certificat, cle)
app = Application(racine)
serveur = None
for p in range(port, port + 20):
try:
serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
gardien = Gardien(p, distant=distant, https=bool(contexte_tls))
serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p),
creer_gestionnaire(app, p, gardien))
port = p
break
except OSError:
continue
if serveur is None:
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
if contexte_tls:
serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True)
serveur.daemon_threads = True
url = f"http://127.0.0.1:{port}/"
schema = "https" if contexte_tls else "http"
url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}"
print(f"🌐 Interface RTG : {url}")
print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)")
if distant:
print(f" Accès depuis le réseau : {schema}://<nom ou adresse de cette machine>:{port}/ (mot de passe)")
if not contexte_tls:
print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre")
print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.")
print(f" Site : {app.racine}")
print(" (Ctrl+C pour arrêter)")
if ouvrir:
+282
View File
@@ -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: '<span class="mk ' + m.type + (m.deplace ? " deplace" : "") + '"></span>' });
}
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 = '<li class="vide">Aucun</li>';
}
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);
})();
+40
View File
@@ -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; }
}
+1
View File
@@ -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);
+23 -8
View File
@@ -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)
+1 -1
View File
@@ -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
+80 -2
View File
@@ -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)