Interface : corrections à la souris et accès protégé
Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer les médias sans position, masquer des zones de trace (pour un voyage, ou zone privée pour tous). Corrections notées dans voyage.toml ([positions], [corrections]) ou site.toml ([confidentialite]), appliquées par les pages ; les originaux ne changent pas. L'extraction garde les médias datés sans position pour qu'on puisse les placer. Accès : clé d'accès aléatoire à chaque lancement, session par cookie HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe, empreinte scrypt hors du site, blocage après 5 échecs), accès réseau seulement avec mot de passe (--distant), HTTPS possible (--certificat, --cle), Content-Security-Policy et interdiction des cadres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
1 parent
06153a227e
commit
71779f6f53
17 files changed
+1047
-38
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ""))
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -1,16 +1,23 @@
|
||||
"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
|
||||
"""Interface de RTG : un petit serveur web, ouvert dans le navigateur.
|
||||
|
||||
Rien n'est installé en plus : le serveur vient de Python, les pages sont des
|
||||
modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
|
||||
fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
|
||||
fournis dans static/. Par défaut le serveur n'écoute que sur 127.0.0.1 ;
|
||||
`--distant` l'ouvre au réseau, avec mot de passe obligatoire.
|
||||
|
||||
Sécurité : une page web quelconque ouverte dans le navigateur pourrait
|
||||
envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
|
||||
doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
|
||||
sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
|
||||
Sécurité (voir aussi securite.py) :
|
||||
- il faut la clé d'accès du lancement ou le mot de passe pour entrer ;
|
||||
- une page web quelconque ouverte dans le navigateur pourrait envoyer des
|
||||
requêtes à ce serveur : toute requête qui modifie quelque chose doit donc
|
||||
porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter sans
|
||||
l'accord du serveur) ;
|
||||
- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ;
|
||||
- les pages de l'interface interdisent leur affichage dans un cadre et
|
||||
n'exécutent que les scripts servis par RTG (Content-Security-Policy).
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import ssl
|
||||
import mimetypes
|
||||
import os
|
||||
import platform
|
||||
@@ -26,10 +33,11 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from rtg import __version__, accueil, site, sources, video
|
||||
from rtg import __version__, acces, accueil, corrections, site, sources, video
|
||||
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
|
||||
reglages_site, reglages_voyage)
|
||||
from rtg.interface import taches
|
||||
from rtg.interface.securite import Gardien
|
||||
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
|
||||
|
||||
ICI = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -108,6 +116,9 @@ class Application:
|
||||
("POST", r"/voyage/date", self.voyage_date),
|
||||
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
|
||||
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
|
||||
("GET", r"/voyage/carte.json", self.voyage_carte),
|
||||
("POST", r"/voyage/position", self.voyage_position),
|
||||
("POST", r"/voyage/masque", self.voyage_masque),
|
||||
("GET", r"/dossiers", self.dossiers),
|
||||
("GET", r"/reglages", self.reglages),
|
||||
("POST", r"/reglages", self.reglages_enregistrer),
|
||||
@@ -260,10 +271,12 @@ class Application:
|
||||
except OSError:
|
||||
return (f, None, None)
|
||||
fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
|
||||
autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
|
||||
autres.append(os.path.join(self.racine, SITE_TOML))
|
||||
photos = cfg["sources"].get("photos", [])
|
||||
return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
|
||||
autres = [os.path.join(chemin, n) for n in ("export_photos.csv", "export_videos.csv")]
|
||||
# Seuls les réglages qui changent l'analyse comptent : déplacer une
|
||||
# photo sur la carte ne doit pas faire relire toutes les traces.
|
||||
reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"],
|
||||
cfg.get("exclusions", {})], sort_keys=True, default=str)
|
||||
return tuple(etat(f) for f in fichiers + autres) + (reglages,)
|
||||
|
||||
def analyse(self, chemin):
|
||||
cfg = self.cfg_voyage(chemin)
|
||||
@@ -341,6 +354,92 @@ class Application:
|
||||
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
|
||||
return page("_infos_utiles.html", i=infos, cfg=cfg)
|
||||
|
||||
# --- Carte et corrections
|
||||
def _media_json(self, chemin, rel, m):
|
||||
nom = m["fichier"]
|
||||
if m["type"] == "photo":
|
||||
dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos"
|
||||
vignette = f"/site/{quote(rel)}/{dossier}/{quote(nom)}"
|
||||
else:
|
||||
base = os.path.splitext(nom)[0]
|
||||
ap = os.path.join(chemin, "apercus", base + ".jpg")
|
||||
vignette = f"/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None
|
||||
d = m["date"][:10].replace(":", "-")
|
||||
return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette)
|
||||
|
||||
def _masques_json(self, cfg):
|
||||
return {"masques": cfg.get("corrections", {}).get("masques", []),
|
||||
"zones": cfg.get("confidentialite", {}).get("zones", [])}
|
||||
|
||||
def voyage_carte(self, r):
|
||||
rel = r.q("v")
|
||||
chemin = self.chemin_voyage(rel)
|
||||
cfg, a, jours, _ = self.analyse(chemin)
|
||||
cfg = self.cfg_voyage(chemin) # positions et masques à jour
|
||||
medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)]
|
||||
return json_rep(dict(
|
||||
self._masques_json(cfg),
|
||||
couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)],
|
||||
jours=self._jours_carte(jours, medias),
|
||||
medias=medias))
|
||||
|
||||
@staticmethod
|
||||
def _jours_carte(jours, medias):
|
||||
"""Jours des traces, plus ceux où seuls des médias existent."""
|
||||
res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])}
|
||||
for j in jours}
|
||||
for m in medias:
|
||||
if m["jour"] not in res:
|
||||
try:
|
||||
d = date.fromisoformat(m["jour"])
|
||||
except ValueError:
|
||||
continue
|
||||
res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"}
|
||||
return [res[k] for k in sorted(res)]
|
||||
|
||||
def voyage_position(self, r):
|
||||
rel = r.f("v")
|
||||
chemin = self.chemin_voyage(rel)
|
||||
nom = r.f("fichier")
|
||||
if not nom:
|
||||
raise Erreur(400, "Média non précisé")
|
||||
if r.f("lat") and r.f("lon"):
|
||||
try:
|
||||
lat, lon = float(r.f("lat")), float(r.f("lon"))
|
||||
except ValueError:
|
||||
raise Erreur(400, "Position invalide")
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
raise Erreur(400, "Position invalide")
|
||||
self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}})
|
||||
else:
|
||||
self.toml_voyage(chemin, {"positions": {nom: None}})
|
||||
cfg = self.cfg_voyage(chemin)
|
||||
m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None)
|
||||
return json_rep({"media": self._media_json(chemin, rel, m) if m else None})
|
||||
|
||||
def voyage_masque(self, r):
|
||||
chemin = self.chemin_voyage(r.f("v"))
|
||||
portee = "site" if r.f("portee") == "site" else "voyage"
|
||||
cfg = self.cfg_voyage(chemin)
|
||||
liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"])
|
||||
if r.f("action") == "ajouter":
|
||||
try:
|
||||
lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon")))
|
||||
except ValueError:
|
||||
raise Erreur(400, "Zone invalide")
|
||||
liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))])
|
||||
elif r.f("action") == "supprimer":
|
||||
try:
|
||||
del liste[int(r.f("index"))]
|
||||
except (ValueError, IndexError):
|
||||
raise Erreur(400, "Zone introuvable")
|
||||
if portee == "site":
|
||||
modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}},
|
||||
entete="Réglages du site RoadTripsGenerator (rtg).")
|
||||
else:
|
||||
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
|
||||
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
|
||||
|
||||
# --- Navigateur de dossiers
|
||||
def dossiers(self, r):
|
||||
chemin = r.q("chemin") or os.path.expanduser("~")
|
||||
@@ -511,11 +610,21 @@ class Requete:
|
||||
return _valeur(self.form, k, defaut)
|
||||
|
||||
|
||||
def creer_gestionnaire(app, port):
|
||||
# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds
|
||||
# de carte, aucun affichage dans un cadre.
|
||||
CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; "
|
||||
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
PUBLIQUES = {"/connexion"}
|
||||
|
||||
|
||||
def creer_gestionnaire(app, port, gardien=None):
|
||||
gardien = gardien or Gardien(port)
|
||||
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
|
||||
|
||||
class Gestionnaire(BaseHTTPRequestHandler):
|
||||
server_version = f"rtg/{__version__}"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # le terminal sert au journal des traitements
|
||||
@@ -529,23 +638,59 @@ def creer_gestionnaire(app, port):
|
||||
def do_POST(self):
|
||||
self.traiter("POST")
|
||||
|
||||
def session(self):
|
||||
for morceau in (self.headers.get("Cookie") or "").split(";"):
|
||||
nom, _, valeur = morceau.strip().partition("=")
|
||||
if nom == gardien.cookie:
|
||||
return valeur
|
||||
return ""
|
||||
|
||||
def rediriger(self, cible, cookie=None):
|
||||
self.send_response(303)
|
||||
self.send_header("Location", cible)
|
||||
if cookie:
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def traiter(self, methode, tete=False):
|
||||
try:
|
||||
if self.headers.get("Host") not in hotes:
|
||||
if not gardien.distant and self.headers.get("Host") not in hotes:
|
||||
raise Erreur(403, "Hôte refusé")
|
||||
url = urlsplit(self.path)
|
||||
chemin = unquote(url.path)
|
||||
query = parse_qs(url.query, keep_blank_values=True)
|
||||
if methode == "GET" and chemin.startswith("/static/"):
|
||||
return self.fichier(STATIC, chemin[len("/static/"):], tete)
|
||||
if methode == "GET" and chemin.startswith("/site/"):
|
||||
return self.fichier(app.racine, chemin[len("/site/"):], tete)
|
||||
# Adresse ouverte au lancement : la clé ouvre une session, puis
|
||||
# on la retire de l'adresse (historique, copier-coller).
|
||||
if "jeton" in query:
|
||||
if gardien.jeton_valide(_valeur(query, "jeton", "")):
|
||||
sid = gardien.ouvrir_session()
|
||||
return self.rediriger(url.path or "/", gardien.entete_cookie(sid))
|
||||
return self.rediriger("/connexion")
|
||||
connecte = gardien.session_valide(self.session())
|
||||
form = {}
|
||||
if methode == "POST":
|
||||
if self.headers.get("HX-Request") != "true":
|
||||
raise Erreur(403, "Requête refusée")
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > 2_000_000:
|
||||
raise Erreur(413, "Requête trop grosse")
|
||||
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
|
||||
req = Requete(parse_qs(url.query, keep_blank_values=True), form)
|
||||
if chemin in PUBLIQUES:
|
||||
return self.envoyer(self.connexion(methode, form, connecte), tete)
|
||||
if not connecte:
|
||||
if self.headers.get("HX-Request") == "true":
|
||||
return self.envoyer(Reponse("", entetes={"HX-Redirect": "/connexion"}), tete)
|
||||
return self.rediriger("/connexion")
|
||||
if chemin == "/deconnexion" and methode == "POST":
|
||||
gardien.fermer_session(self.session())
|
||||
return self.envoyer(Reponse("", entetes={
|
||||
"HX-Redirect": "/connexion", "Set-Cookie": gardien.entete_cookie("", effacer=True)}), tete)
|
||||
if methode == "GET" and chemin.startswith("/site/"):
|
||||
return self.fichier(app.racine, chemin[len("/site/"):], tete)
|
||||
req = Requete(query, form)
|
||||
for m, motif, fn in app.routes:
|
||||
if m == methode and motif.match(chemin):
|
||||
rep = fn(req)
|
||||
@@ -568,11 +713,26 @@ def creer_gestionnaire(app, port):
|
||||
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
|
||||
self.envoyer(rep, tete)
|
||||
|
||||
def connexion(self, methode, form, connecte):
|
||||
ip = self.client_address[0]
|
||||
if methode == "POST":
|
||||
ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", ""))
|
||||
if ok:
|
||||
sid = gardien.ouvrir_session()
|
||||
return Reponse("", entetes={"HX-Redirect": "/", "Set-Cookie": gardien.entete_cookie(sid)})
|
||||
return Reponse(f'<div class="erreur">{_echapper(message)}</div>')
|
||||
return page("connexion.html", mdp=acces.defini(), connecte=connecte,
|
||||
distant=gardien.distant, attente=gardien.attente(ip))
|
||||
|
||||
def envoyer(self, rep, tete=False):
|
||||
self.send_response(rep.code)
|
||||
self.send_header("Content-Type", rep.type)
|
||||
self.send_header("Content-Length", str(len(rep.corps)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", CSP)
|
||||
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
for k, v in rep.entetes.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
@@ -642,21 +802,41 @@ def _echapper(s):
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def lancer(racine, port=8765, ouvrir=True):
|
||||
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None):
|
||||
"""Lance l'interface. distant=True : écoute sur toutes les interfaces
|
||||
réseau, mot de passe obligatoire ; certificat et cle : HTTPS."""
|
||||
if distant and not acces.defini():
|
||||
raise SystemExit("❌ L'accès distant demande un mot de passe : lancez d'abord « rtg mot-de-passe ».")
|
||||
contexte_tls = None
|
||||
if certificat:
|
||||
contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
contexte_tls.load_cert_chain(certificat, cle)
|
||||
app = Application(racine)
|
||||
serveur = None
|
||||
for p in range(port, port + 20):
|
||||
try:
|
||||
serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
|
||||
gardien = Gardien(p, distant=distant, https=bool(contexte_tls))
|
||||
serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p),
|
||||
creer_gestionnaire(app, p, gardien))
|
||||
port = p
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if serveur is None:
|
||||
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
|
||||
if contexte_tls:
|
||||
serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True)
|
||||
serveur.daemon_threads = True
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
schema = "https" if contexte_tls else "http"
|
||||
url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}"
|
||||
print(f"🌐 Interface RTG : {url}")
|
||||
print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)")
|
||||
if distant:
|
||||
print(f" Accès depuis le réseau : {schema}://<nom ou adresse de cette machine>:{port}/ (mot de passe)")
|
||||
if not contexte_tls:
|
||||
print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre")
|
||||
print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.")
|
||||
print(f" Site : {app.racine}")
|
||||
print(" (Ctrl+C pour arrêter)")
|
||||
if ouvrir:
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user