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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user